From 9a2cc92a6ff0ee6dab5c9840341c4dffa31dd871 Mon Sep 17 00:00:00 2001 From: Ilya Groshev Date: Sat, 9 May 2026 17:18:48 +0300 Subject: [PATCH] Fix Main Quests replay and weapon awaken level cap --- server/cmd/lunar-tear/grpc.go | 2 +- server/internal/masterdata/quest.go | 83 +++++++++ server/internal/questflow/event_quest.go | 2 + server/internal/questflow/quest.go | 167 +++++++++++++----- server/internal/questflow/scene.go | 36 +++- server/internal/service/quest_main.go | 3 +- server/internal/service/user.go | 26 ++- server/internal/service/weapon.go | 15 +- server/internal/store/sqlite/load.go | 26 ++- server/internal/store/sqlite/save.go | 36 +++- server/internal/store/sqlite/user.go | 1 + server/internal/store/types.go | 31 +++- server/internal/userdata/changed_tables.go | 6 +- server/internal/userdata/proj_quest.go | 35 +++- ...507181003_add_main_quest_season_routes.sql | 19 ++ ...20260508094826_add_saved_quest_context.sql | 17 ++ 16 files changed, 440 insertions(+), 65 deletions(-) create mode 100644 server/migrations/20260507181003_add_main_quest_season_routes.sql create mode 100644 server/migrations/20260508094826_add_saved_quest_context.sql diff --git a/server/cmd/lunar-tear/grpc.go b/server/cmd/lunar-tear/grpc.go index ed9843e..818bd2a 100644 --- a/server/cmd/lunar-tear/grpc.go +++ b/server/cmd/lunar-tear/grpc.go @@ -88,7 +88,7 @@ func registerServices( pubPort, _ := strconv.Atoi(pubPortStr) pb.RegisterBannerServiceServer(srv, service.NewBannerServiceServer(holder)) - pb.RegisterUserServiceServer(srv, service.NewUserServiceServer(userStore, userStore, authURL, noRegister)) + pb.RegisterUserServiceServer(srv, service.NewUserServiceServer(userStore, userStore, holder, authURL, noRegister)) pb.RegisterBattleServiceServer(srv, service.NewBattleServiceServer(userStore, userStore)) pb.RegisterConfigServiceServer(srv, service.NewConfigServiceServer(pubHost, int32(pubPort), octoURL)) pb.RegisterDataServiceServer(srv, service.NewDataServiceServer(userStore, userStore)) diff --git a/server/internal/masterdata/quest.go b/server/internal/masterdata/quest.go index 9e2c5d7..2181898 100644 --- a/server/internal/masterdata/quest.go +++ b/server/internal/masterdata/quest.go @@ -12,6 +12,34 @@ type BattleDropInfo struct { BattleDropCategoryId int32 } +// SeasonRoutePair pairs a main-quest season with one of its routes, used to +// reconstruct a player's progression history for IUserMainQuestSeasonRoute. +type SeasonRoutePair struct { + MainQuestSeasonId int32 + MainQuestRouteId int32 +} + +// SeasonRoutesUpToCurrent returns every (season, route) pair from +// OrderedSeasonRoutes whose ordering is <= the given (seasonId, routeId) +// pair, inclusive. Used at user-load time to backfill +// IUserMainQuestSeasonRoute history so the client can compute the next +// route correctly when the player advances past a chapter end. Returns +// nil if the given pair isn't found. +func (q *QuestCatalog) SeasonRoutesUpToCurrent(seasonId, routeId int32) []SeasonRoutePair { + if q == nil { + return nil + } + out := make([]SeasonRoutePair, 0, len(q.OrderedSeasonRoutes)) + for _, p := range q.OrderedSeasonRoutes { + out = append(out, p) + if p.MainQuestSeasonId == seasonId && p.MainQuestRouteId == routeId { + return out + } + } + // Pair not found in masterdata — don't return a partial list. + return nil +} + type QuestCatalog struct { SceneById map[int32]EntityMQuestScene MissionById map[int32]EntityMQuestMission @@ -34,6 +62,9 @@ type QuestCatalog struct { TutorialUnlockConditions []EntityMTutorialUnlockCondition ChapterLastSceneByQuestId map[int32]int32 SeasonIdByRouteId map[int32]int32 + OrderedSeasonRoutes []SeasonRoutePair + QuestsWithDifficulty map[int32]bool // any questId referenced in m_quest_relation_main_flow + BattleOnlyTargetSceneByQuestId map[int32]int32 UserExpThresholds []int32 CharacterExpThresholds []int32 @@ -117,6 +148,22 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) { seasonIdByRouteId[r.MainQuestRouteId] = r.MainQuestSeasonId } + orderedRoutes := make([]EntityMMainQuestRoute, len(routes)) + copy(orderedRoutes, routes) + sort.Slice(orderedRoutes, func(i, j int) bool { + if orderedRoutes[i].MainQuestSeasonId != orderedRoutes[j].MainQuestSeasonId { + return orderedRoutes[i].MainQuestSeasonId < orderedRoutes[j].MainQuestSeasonId + } + return orderedRoutes[i].SortOrder < orderedRoutes[j].SortOrder + }) + orderedSeasonRoutes := make([]SeasonRoutePair, 0, len(orderedRoutes)) + for _, r := range orderedRoutes { + orderedSeasonRoutes = append(orderedSeasonRoutes, SeasonRoutePair{ + MainQuestSeasonId: r.MainQuestSeasonId, + MainQuestRouteId: r.MainQuestRouteId, + }) + } + firstClearSwitches, err := utils.ReadTable[EntityMQuestFirstClearRewardSwitch]("m_quest_first_clear_reward_switch") if err != nil { return nil, fmt.Errorf("load quest first clear reward switch table: %w", err) @@ -238,6 +285,30 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) { return nil, fmt.Errorf("load tutorial unlock condition table: %w", err) } + relations, err := utils.ReadTable[EntityMQuestRelationMainFlow]("m_quest_relation_main_flow") + if err != nil { + return nil, fmt.Errorf("load quest relation main flow table: %w", err) + } + questsWithDifficulty := make(map[int32]bool, len(relations)*3) + for _, r := range relations { + questsWithDifficulty[r.MainFlowQuestId] = true + if r.ReplayFlowQuestId != 0 { + questsWithDifficulty[r.ReplayFlowQuestId] = true + } + if r.SubFlowQuestId != 0 { + questsWithDifficulty[r.SubFlowQuestId] = true + } + } + + battleOnlyTargetSceneByQuestId := make(map[int32]int32) + for _, scene := range scenes { + if scene.IsBattleOnlyTarget { + if _, exists := battleOnlyTargetSceneByQuestId[scene.QuestId]; !exists { + battleOnlyTargetSceneByQuestId[scene.QuestId] = scene.QuestSceneId + } + } + } + paramMapRows, err := LoadParameterMap() if err != nil { return nil, err @@ -529,6 +600,9 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) { TutorialUnlockConditions: tutorialUnlockConds, ChapterLastSceneByQuestId: chapterLastSceneByQuestId, SeasonIdByRouteId: seasonIdByRouteId, + OrderedSeasonRoutes: orderedSeasonRoutes, + QuestsWithDifficulty: questsWithDifficulty, + BattleOnlyTargetSceneByQuestId: battleOnlyTargetSceneByQuestId, UserExpThresholds: BuildExpThresholds(paramMapRows, 1), CharacterExpThresholds: BuildExpThresholds(paramMapRows, 31), @@ -545,3 +619,12 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) { PartsCatalog: partsCatalog, }, nil } + +func (q *QuestCatalog) BattleOnlyTargetSceneIdFor(questId int32) (int32, bool) { + v, ok := q.BattleOnlyTargetSceneByQuestId[questId] + return v, ok +} + +func (q *QuestCatalog) QuestHasDifficulty(questId int32) bool { + return q.QuestsWithDifficulty[questId] +} diff --git a/server/internal/questflow/event_quest.go b/server/internal/questflow/event_quest.go index b1a508f..424f735 100644 --- a/server/internal/questflow/event_quest.go +++ b/server/internal/questflow/event_quest.go @@ -53,9 +53,11 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) } + user.EventQuest.CurrentEventQuestChapterId = 0 user.EventQuest.CurrentQuestId = 0 user.EventQuest.CurrentQuestSceneId = 0 user.EventQuest.HeadQuestSceneId = 0 + user.EventQuest.LatestVersion = nowMillis h.clearQuestMissions(user, questId, nowMillis) diff --git a/server/internal/questflow/quest.go b/server/internal/questflow/quest.go index ce1ffe9..50824aa 100644 --- a/server/internal/questflow/quest.go +++ b/server/internal/questflow/quest.go @@ -38,49 +38,62 @@ func (h *QuestHandler) clearQuestMissions(user *store.UserState, questId int32, } } -func (h *QuestHandler) HandleQuestStart(user *store.UserState, questId int32, isBattleOnly bool, userDeckNumber int32, nowMillis int64) { - h.handleQuestStartInternal(user, questId, isBattleOnly, userDeckNumber, false, nowMillis) +func (h *QuestHandler) HandleQuestStart(user *store.UserState, questId int32, isBattleOnly, isMainFlow bool, userDeckNumber int32, nowMillis int64) { + h.handleQuestStartInternal(user, questId, isBattleOnly, isMainFlow, userDeckNumber, false, nowMillis) } func (h *QuestHandler) HandleQuestStartReplay(user *store.UserState, questId int32, isBattleOnly bool, userDeckNumber int32, nowMillis int64) { - h.handleQuestStartInternal(user, questId, isBattleOnly, userDeckNumber, true, nowMillis) + h.handleQuestStartInternal(user, questId, isBattleOnly, false, userDeckNumber, true, nowMillis) } -func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId int32, isBattleOnly bool, userDeckNumber int32, isReplayFlow bool, nowMillis int64) { +func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId int32, isBattleOnly, isMainFlow bool, userDeckNumber int32, isReplayFlow bool, nowMillis int64) { quest, ok := h.QuestById[questId] if !ok { panic(fmt.Sprintf("unknown questId=%d for HandleQuestStart", questId)) } h.initQuestState(user, questId) - if quest.Stamina > 0 { - maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 - store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis) + store.ConsumeStamina(user, quest.Stamina, h.MaxStaminaByLevel[user.Status.Level]*1000, nowMillis) } questState := user.Quests[questId] - if questState.QuestStateType == model.UserQuestStateTypeCleared { - if isReplayFlow { - user.MainQuest.SavedCurrentQuestSceneId = user.MainQuest.CurrentQuestSceneId - user.MainQuest.SavedHeadQuestSceneId = user.MainQuest.HeadQuestSceneId - user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeReplayFlow) - user.MainQuest.ReplayFlowCurrentQuestSceneId = 0 - user.MainQuest.ReplayFlowHeadQuestSceneId = 0 - user.MainQuest.LatestVersion = nowMillis - questState.QuestStateType = model.UserQuestStateTypeActive - questState.LatestStartDatetime = nowMillis - questState.IsBattleOnly = isBattleOnly - questState.UserDeckNumber = userDeckNumber - user.Quests[questId] = questState - log.Printf("[HandleQuestStart] replay flow started for quest %d, saved scene=%d head=%d", - questId, user.MainQuest.SavedCurrentQuestSceneId, user.MainQuest.SavedHeadQuestSceneId) - } + questState.IsBattleOnly = isBattleOnly + questState.UserDeckNumber = userDeckNumber + + isCleared := questState.QuestStateType == model.UserQuestStateTypeCleared + isMenuPick := !isReplayFlow && !isMainFlow && (isCleared || h.QuestHasDifficulty(questId)) + + switch { + case isMenuPick: + snapshotMainQuestIfNeeded(user) + sceneId := h.menuPickSceneId(questId, isBattleOnly) + user.MainQuest.ProgressQuestSceneId = sceneId + user.MainQuest.ProgressHeadQuestSceneId = sceneId + user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeMainFlow) + user.PortalCageStatus.IsCurrentProgress = false + user.PortalCageStatus.LatestVersion = nowMillis + user.SideStoryActiveProgress = store.SideStoryActiveProgress{LatestVersion: nowMillis} + user.MainQuest.LatestVersion = nowMillis + log.Printf("[HandleQuestStart] QuestMenuPick quest=%d isBattleOnly=%v scene=%d cleared=%v", + questId, isBattleOnly, sceneId, isCleared) + + case isCleared && isReplayFlow: + snapshotMainQuestIfNeeded(user) + user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeReplayFlow) + user.MainQuest.ReplayFlowCurrentQuestSceneId = 0 + user.MainQuest.ReplayFlowHeadQuestSceneId = 0 + user.MainQuest.LatestVersion = nowMillis + log.Printf("[HandleQuestStart] MapPlay quest=%d isBattleOnly=%v", questId, isBattleOnly) + } + + if isCleared { + questState.QuestStateType = model.UserQuestStateTypeActive + questState.LatestStartDatetime = nowMillis + user.Quests[questId] = questState return } - questState.IsBattleOnly = isBattleOnly - questState.UserDeckNumber = userDeckNumber if isMainQuestPlayable(quest) { user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow) questState.QuestStateType = model.UserQuestStateTypeActive @@ -90,7 +103,6 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i questState.ClearCount = 1 questState.DailyClearCount = 1 questState.LastClearDatetime = nowMillis - if sceneIds := h.SceneIdsByQuestId[questId]; len(sceneIds) > 0 { firstSceneId := sceneIds[0] prevSceneId := user.MainQuest.CurrentQuestSceneId @@ -102,6 +114,33 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i user.Quests[questId] = questState } +func snapshotMainQuestIfNeeded(user *store.UserState) { + if user.MainQuest.SavedContext.Active { + return + } + user.MainQuest.SavedContext = store.SavedQuestContext{ + Active: true, + CurrentQuestSceneId: user.MainQuest.CurrentQuestSceneId, + HeadQuestSceneId: user.MainQuest.HeadQuestSceneId, + CurrentMainQuestRouteId: user.MainQuest.CurrentMainQuestRouteId, + MainQuestSeasonId: user.MainQuest.MainQuestSeasonId, + IsReachedLastQuestScene: user.MainQuest.IsReachedLastQuestScene, + PortalCageInProgress: user.PortalCageStatus.IsCurrentProgress, + } +} + +func (h *QuestHandler) menuPickSceneId(questId int32, isBattleOnly bool) int32 { + if isBattleOnly { + if v, ok := h.BattleOnlyTargetSceneIdFor(questId); ok { + return v + } + } + if scenes := h.SceneIdsByQuestId[questId]; len(scenes) > 0 { + return scenes[0] + } + return 0 +} + func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, outcome *FinishOutcome, nowMillis int64) { questState := user.Quests[questId] if !questState.IsRewardGranted { @@ -122,22 +161,49 @@ func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, o questState.ClearCount++ questState.DailyClearCount++ questState.LastClearDatetime = nowMillis + questState.IsBattleOnly = false user.Quests[questId] = questState } +func (h *QuestHandler) finalizeChainPreviousQuest(user *store.UserState, questId int32, nowMillis int64) { + if _, ok := h.QuestById[questId]; !ok { + return + } + h.initQuestState(user, questId) + questState := user.Quests[questId] + if questState.QuestStateType == model.UserQuestStateTypeCleared { + return + } + if !questState.IsRewardGranted { + h.applyQuestRewards(user, questId, nowMillis) + questState.IsRewardGranted = true + } + questState.QuestStateType = model.UserQuestStateTypeCleared + questState.ClearCount++ + questState.DailyClearCount++ + questState.LastClearDatetime = nowMillis + questState.IsBattleOnly = false + user.Quests[questId] = questState + h.clearQuestMissions(user, questId, nowMillis) + log.Printf("[HandleMainQuestSceneProgress] finalized chain-previous quest %d (cleared)", questId) +} + func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, isRetired, isAnnihilated bool, nowMillis int64) FinishOutcome { quest, ok := h.QuestById[questId] if !ok { panic(fmt.Sprintf("unknown questId=%d for HandleQuestFinish", questId)) } + h.initQuestState(user, questId) + outcome := h.evaluateFinishOutcome(user, questId) wasReplay := user.MainQuest.CurrentQuestFlowType == int32(model.QuestFlowTypeReplayFlow) + wasMenuReplay := user.MainQuest.SavedContext.Active if !isRetired { h.applyQuestVictory(user, questId, &outcome, nowMillis) - if isMainQuestPlayable(quest) && !wasReplay { + if isMainQuestPlayable(quest) && !wasReplay && !wasMenuReplay { lastSceneId := h.getLastMainFlowSceneId(questId) h.advanceMainFlowScene(user, questId, lastSceneId) } @@ -149,24 +215,43 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) } + // On retire of a previously-cleared quest (cage Menu Pick replay or + // Map Play replay), HandleQuestStart marked QuestStateType=Active for + // the run. With applyQuestVictory skipped on retire, that Active sticks + // and the cage UI shows the quest as locked. Restore Cleared. + if isRetired { + qs := user.Quests[questId] + if qs.ClearCount > 0 && qs.QuestStateType == model.UserQuestStateTypeActive { + qs.QuestStateType = model.UserQuestStateTypeCleared + user.Quests[questId] = qs + } + } + user.MainQuest.ProgressQuestSceneId = 0 user.MainQuest.ProgressHeadQuestSceneId = 0 user.MainQuest.ProgressQuestFlowType = 0 - user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeUnknown) + user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow) + + if wasMenuReplay { + ctx := user.MainQuest.SavedContext + user.MainQuest.CurrentQuestSceneId = ctx.CurrentQuestSceneId + user.MainQuest.HeadQuestSceneId = ctx.HeadQuestSceneId + user.MainQuest.CurrentMainQuestRouteId = ctx.CurrentMainQuestRouteId + user.MainQuest.MainQuestSeasonId = ctx.MainQuestSeasonId + user.MainQuest.IsReachedLastQuestScene = ctx.IsReachedLastQuestScene + user.PortalCageStatus.IsCurrentProgress = ctx.PortalCageInProgress + user.PortalCageStatus.LatestVersion = nowMillis + user.MainQuest.SavedContext = store.SavedQuestContext{} + user.MainQuest.LatestVersion = nowMillis + log.Printf("[HandleQuestFinish] restored snapshot for quest %d (route=%d season=%d scene=%d head=%d cage=%v)", + questId, ctx.CurrentMainQuestRouteId, ctx.MainQuestSeasonId, + ctx.CurrentQuestSceneId, ctx.HeadQuestSceneId, ctx.PortalCageInProgress) + } if wasReplay { - if user.MainQuest.SavedCurrentQuestSceneId > 0 { - user.MainQuest.CurrentQuestSceneId = user.MainQuest.SavedCurrentQuestSceneId - } - if user.MainQuest.SavedHeadQuestSceneId > 0 { - user.MainQuest.HeadQuestSceneId = user.MainQuest.SavedHeadQuestSceneId - } - user.MainQuest.SavedCurrentQuestSceneId = 0 - user.MainQuest.SavedHeadQuestSceneId = 0 user.MainQuest.ReplayFlowCurrentQuestSceneId = 0 user.MainQuest.ReplayFlowHeadQuestSceneId = 0 - log.Printf("[HandleQuestFinish] replay flow ended for quest %d, restored scene=%d head=%d", - questId, user.MainQuest.CurrentQuestSceneId, user.MainQuest.HeadQuestSceneId) + log.Printf("[HandleQuestFinish] replay flow ended for quest %d", questId) } h.clearQuestMissions(user, questId, nowMillis) @@ -215,14 +300,16 @@ func (h *QuestHandler) HandleQuestSkip(user *store.UserState, questId, skipCount func (h *QuestHandler) HandleQuestRestart(user *store.UserState, questId int32, nowMillis int64) { questDef, ok := h.QuestById[questId] - if ok && isMainQuestPlayable(questDef) { + // Only seed CurrentQuestFlowType when it's not already set (initial + // natural progression). Don't clobber an in-flight ReplayFlow (Map Play + // resume). + if ok && isMainQuestPlayable(questDef) && user.MainQuest.CurrentQuestFlowType == 0 { user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow) } quest := user.Quests[questId] quest.QuestId = questId quest.QuestStateType = model.UserQuestStateTypeActive - quest.IsBattleOnly = false quest.LatestStartDatetime = nowMillis user.Quests[questId] = quest diff --git a/server/internal/questflow/scene.go b/server/internal/questflow/scene.go index 8627bcb..36f14b8 100644 --- a/server/internal/questflow/scene.go +++ b/server/internal/questflow/scene.go @@ -27,10 +27,11 @@ func (h *QuestHandler) isSceneAhead(newSceneId, currentHeadId int32) bool { } func (h *QuestHandler) advanceMainFlowScene(user *store.UserState, questId, sceneId int32) { - user.MainQuest.CurrentQuestSceneId = sceneId - if h.isSceneAhead(sceneId, user.MainQuest.HeadQuestSceneId) { - user.MainQuest.HeadQuestSceneId = sceneId + if !h.isSceneAhead(sceneId, user.MainQuest.HeadQuestSceneId) { + return } + user.MainQuest.CurrentQuestSceneId = sceneId + user.MainQuest.HeadQuestSceneId = sceneId lastSceneId := h.getChapterLastSceneId(questId) user.MainQuest.IsReachedLastQuestScene = sceneId == lastSceneId @@ -39,10 +40,33 @@ func (h *QuestHandler) advanceMainFlowScene(user *store.UserState, questId, scen user.MainQuest.CurrentMainQuestRouteId = routeId if seasonId, ok := h.SeasonIdByRouteId[routeId]; ok { user.MainQuest.MainQuestSeasonId = seasonId + RecordSeasonRoute(user, seasonId, routeId, gametime.NowMillis()) } } } +// RecordSeasonRoute upserts the (season, route) pair into the player's history, +// bumping LatestVersion on first insert. The history backs IUserMainQuestSeasonRoute, +// which the client uses to know which chapters' scene metadata to load (so cage +// menu-replay can transition to quests from older chapters without crashing). +func RecordSeasonRoute(user *store.UserState, seasonId, routeId int32, nowMillis int64) { + if seasonId <= 0 || routeId <= 0 { + return + } + if user.MainQuestSeasonRoutes == nil { + user.MainQuestSeasonRoutes = make(map[store.SeasonRouteKey]store.SeasonRouteEntry) + } + key := store.SeasonRouteKey{MainQuestSeasonId: seasonId, MainQuestRouteId: routeId} + if _, exists := user.MainQuestSeasonRoutes[key]; exists { + return + } + user.MainQuestSeasonRoutes[key] = store.SeasonRouteEntry{ + MainQuestSeasonId: seasonId, + MainQuestRouteId: routeId, + LatestVersion: nowMillis, + } +} + func (h *QuestHandler) HandleMainFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) { scene, ok := h.SceneById[questSceneId] if !ok { @@ -127,6 +151,12 @@ func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, quest panic(fmt.Sprintf("unknown questId=%d for HandleMainQuestSceneProgress", questSceneId)) } + if prevSceneId := user.MainQuest.ProgressQuestSceneId; prevSceneId != 0 { + if prevScene, ok := h.SceneById[prevSceneId]; ok && prevScene.QuestId != quest.QuestId { + h.finalizeChainPreviousQuest(user, prevScene.QuestId, gametime.NowMillis()) + } + } + if isMainQuestPlayable(quest) { if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult { nowMillis := gametime.NowMillis() diff --git a/server/internal/service/quest_main.go b/server/internal/service/quest_main.go index 10cba8d..a0bb6dd 100644 --- a/server/internal/service/quest_main.go +++ b/server/internal/service/quest_main.go @@ -74,7 +74,7 @@ func (s *QuestServiceServer) StartMainQuest(ctx context.Context, req *pb.StartMa if req.IsReplayFlow { engine.HandleQuestStartReplay(user, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis) } else { - engine.HandleQuestStart(user, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis) + engine.HandleQuestStart(user, req.QuestId, req.IsBattleOnly, req.IsMainFlow, req.UserDeckNumber, nowMillis) } }) @@ -198,6 +198,7 @@ func (s *QuestServiceServer) SetRoute(ctx context.Context, req *pb.SetRouteReque user.MainQuest.CurrentMainQuestRouteId = req.MainQuestRouteId if seasonId, ok := engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok { user.MainQuest.MainQuestSeasonId = seasonId + questflow.RecordSeasonRoute(user, seasonId, req.MainQuestRouteId, gametime.NowMillis()) } now := gametime.NowMillis() user.PortalCageStatus.IsCurrentProgress = false diff --git a/server/internal/service/user.go b/server/internal/service/user.go index f49366b..065bb96 100644 --- a/server/internal/service/user.go +++ b/server/internal/service/user.go @@ -19,6 +19,8 @@ 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/runtime" "lunar-tear/server/internal/store" ) @@ -26,15 +28,16 @@ type UserServiceServer struct { pb.UnimplementedUserServiceServer users store.UserRepository sessions store.SessionRepository + holder *runtime.Holder authURL string noRegister bool } -func NewUserServiceServer(users store.UserRepository, sessions store.SessionRepository, authURL string, noRegister bool) *UserServiceServer { +func NewUserServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder, authURL string, noRegister bool) *UserServiceServer { if authURL != "" && !strings.Contains(authURL, "://") { authURL = "http://" + authURL } - return &UserServiceServer{users: users, sessions: sessions, authURL: authURL, noRegister: noRegister} + return &UserServiceServer{users: users, sessions: sessions, holder: holder, authURL: authURL, noRegister: noRegister} } func (s *UserServiceServer) RegisterUser(ctx context.Context, req *pb.RegisterUserRequest) (*pb.RegisterUserResponse, error) { @@ -93,7 +96,24 @@ func (s *UserServiceServer) GameStart(ctx context.Context, _ *emptypb.Empty) (*p userId := CurrentUserId(ctx, s.users, s.sessions) s.users.UpdateUser(userId, func(user *store.UserState) { - user.GameStartDatetime = gametime.NowMillis() + now := gametime.NowMillis() + user.GameStartDatetime = now + + // Backfill IUserMainQuestSeasonRoute history so the client can compute + // the next route after a chapter end. Idempotent: RecordSeasonRoute + // is a no-op for entries that already exist. + if catalog := s.holder.Get(); catalog != nil && catalog.QuestHandler != nil { + if user.MainQuest.MainQuestSeasonId > 0 && user.MainQuest.CurrentMainQuestRouteId > 0 { + before := len(user.MainQuestSeasonRoutes) + for _, p := range catalog.QuestHandler.SeasonRoutesUpToCurrent(user.MainQuest.MainQuestSeasonId, user.MainQuest.CurrentMainQuestRouteId) { + questflow.RecordSeasonRoute(user, p.MainQuestSeasonId, p.MainQuestRouteId, now) + } + if added := len(user.MainQuestSeasonRoutes) - before; added > 0 { + user.MainQuest.LatestVersion = now + log.Printf("[UserService] GameStart: backfilled %d MainQuestSeasonRoute entries", added) + } + } + } }) return &pb.GameStartResponse{}, nil diff --git a/server/internal/service/weapon.go b/server/internal/service/weapon.go index 2db1624..4ddb6c8 100644 --- a/server/internal/service/weapon.go +++ b/server/internal/service/weapon.go @@ -126,7 +126,7 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh if thresholds, ok := catalog.ExpByEnhanceId[levelingEnhanceId]; ok { weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds) if maxFunc, ok := catalog.MaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]; ok { - cap := maxFunc.Evaluate(weapon.LimitBreakCount) + cap := awakenedLevelCap(catalog, user, weapon, req.UserWeaponUuid, maxFunc.Evaluate(weapon.LimitBreakCount)) if weapon.Level > cap { weapon.Level = cap if int(cap) >= 0 && int(cap) < len(thresholds) { @@ -748,7 +748,7 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan if thresholds, ok := catalog.ExpByEnhanceId[levelingEnhanceId]; ok { weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds) if maxFunc, ok := catalog.MaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]; ok { - cap := maxFunc.Evaluate(weapon.LimitBreakCount) + cap := awakenedLevelCap(catalog, user, weapon, req.UserWeaponUuid, maxFunc.Evaluate(weapon.LimitBreakCount)) if weapon.Level > cap { weapon.Level = cap if int(cap) >= 0 && int(cap) < len(thresholds) { @@ -878,3 +878,14 @@ func (s *WeaponServiceServer) Awaken(ctx context.Context, req *pb.WeaponAwakenRe return &pb.WeaponAwakenResponse{}, nil } + +func awakenedLevelCap(catalog *masterdata.WeaponCatalog, user *store.UserState, weapon store.WeaponState, weaponUuid string, baseCap int32) int32 { + if _, awoken := user.WeaponAwakens[weaponUuid]; !awoken { + return baseCap + } + row, ok := catalog.AwakenByWeaponId[weapon.WeaponId] + if !ok { + return baseCap + } + return baseCap + row.LevelLimitUp +} diff --git a/server/internal/store/sqlite/load.go b/server/internal/store/sqlite/load.go index e384e86..4fe982b 100644 --- a/server/internal/store/sqlite/load.go +++ b/server/internal/store/sqlite/load.go @@ -81,6 +81,7 @@ func initMaps(u *store.UserState) { u.CharacterRebirths = make(map[int32]store.CharacterRebirthState) u.AutoSaleSettings = make(map[int32]store.AutoSaleSettingState) u.SideStoryQuests = make(map[int32]store.SideStoryQuestProgress) + u.MainQuestSeasonRoutes = make(map[store.SeasonRouteKey]store.SeasonRouteEntry) u.QuestLimitContentStatus = make(map[int32]store.QuestLimitContentStatus) u.BigHuntMaxScores = make(map[int32]store.BigHuntMaxScore) u.BigHuntStatuses = make(map[int32]store.BigHuntStatus) @@ -124,17 +125,26 @@ func load1to1(db *sql.DB, uid int64, u *store.UserState) { Scan(&u.LoginBonus.LoginBonusId, &u.LoginBonus.CurrentPageNumber, &u.LoginBonus.CurrentStampNumber, &u.LoginBonus.LatestRewardReceiveDatetime, &u.LoginBonus.LatestVersion) + var ctxActive, ctxIsLast, ctxCage int _ = db.QueryRow(`SELECT current_quest_flow_type, current_main_quest_route_id, current_quest_scene_id, head_quest_scene_id, is_reached_last_quest_scene, progress_quest_scene_id, progress_head_quest_scene_id, - progress_quest_flow_type, main_quest_season_id, latest_version, saved_current_quest_scene_id, - saved_head_quest_scene_id, replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id + progress_quest_flow_type, main_quest_season_id, latest_version, + saved_ctx_active, saved_ctx_current_quest_scene_id, saved_ctx_head_quest_scene_id, + saved_ctx_current_main_quest_route_id, saved_ctx_main_quest_season_id, + saved_ctx_is_reached_last_quest_scene, saved_ctx_portal_cage_in_progress, + replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id FROM user_main_quest WHERE user_id=?`, uid). Scan(&u.MainQuest.CurrentQuestFlowType, &u.MainQuest.CurrentMainQuestRouteId, &u.MainQuest.CurrentQuestSceneId, &u.MainQuest.HeadQuestSceneId, &b, &u.MainQuest.ProgressQuestSceneId, &u.MainQuest.ProgressHeadQuestSceneId, &u.MainQuest.ProgressQuestFlowType, &u.MainQuest.MainQuestSeasonId, &u.MainQuest.LatestVersion, - &u.MainQuest.SavedCurrentQuestSceneId, &u.MainQuest.SavedHeadQuestSceneId, + &ctxActive, &u.MainQuest.SavedContext.CurrentQuestSceneId, &u.MainQuest.SavedContext.HeadQuestSceneId, + &u.MainQuest.SavedContext.CurrentMainQuestRouteId, &u.MainQuest.SavedContext.MainQuestSeasonId, + &ctxIsLast, &ctxCage, &u.MainQuest.ReplayFlowCurrentQuestSceneId, &u.MainQuest.ReplayFlowHeadQuestSceneId) u.MainQuest.IsReachedLastQuestScene = b != 0 + u.MainQuest.SavedContext.Active = ctxActive != 0 + u.MainQuest.SavedContext.IsReachedLastQuestScene = ctxIsLast != 0 + u.MainQuest.SavedContext.PortalCageInProgress = ctxCage != 0 _ = db.QueryRow(`SELECT current_event_quest_chapter_id, current_quest_id, current_quest_scene_id, head_quest_scene_id, latest_version FROM user_event_quest WHERE user_id=?`, uid). @@ -346,6 +356,16 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) { } }) + queryRows(db, `SELECT main_quest_season_id, main_quest_route_id, latest_version + FROM user_main_quest_season_routes WHERE user_id=?`, uid, func(rows *sql.Rows) { + var seasonId, routeId int32 + var lv int64 + rows.Scan(&seasonId, &routeId, &lv) + u.MainQuestSeasonRoutes[store.SeasonRouteKey{MainQuestSeasonId: seasonId, MainQuestRouteId: routeId}] = store.SeasonRouteEntry{ + MainQuestSeasonId: seasonId, MainQuestRouteId: routeId, LatestVersion: lv, + } + }) + queryRows(db, `SELECT limit_content_id, limit_content_quest_status_type, event_quest_chapter_id, latest_version FROM user_quest_limit_content_status WHERE user_id=?`, uid, func(rows *sql.Rows) { var id int32 diff --git a/server/internal/store/sqlite/save.go b/server/internal/store/sqlite/save.go index 238da56..dbbf2f5 100644 --- a/server/internal/store/sqlite/save.go +++ b/server/internal/store/sqlite/save.go @@ -50,11 +50,16 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error { u.LoginBonus.LatestRewardReceiveDatetime, u.LoginBonus.LatestVersion); err != nil { return err } - if err := exec(`INSERT INTO user_main_quest (user_id, current_quest_flow_type, current_main_quest_route_id, current_quest_scene_id, head_quest_scene_id, is_reached_last_quest_scene, progress_quest_scene_id, progress_head_quest_scene_id, progress_quest_flow_type, main_quest_season_id, latest_version, saved_current_quest_scene_id, saved_head_quest_scene_id, replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + if err := exec(`INSERT INTO user_main_quest (user_id, current_quest_flow_type, current_main_quest_route_id, current_quest_scene_id, head_quest_scene_id, is_reached_last_quest_scene, progress_quest_scene_id, progress_head_quest_scene_id, progress_quest_flow_type, main_quest_season_id, latest_version, saved_ctx_active, saved_ctx_current_quest_scene_id, saved_ctx_head_quest_scene_id, saved_ctx_current_main_quest_route_id, saved_ctx_main_quest_season_id, saved_ctx_is_reached_last_quest_scene, saved_ctx_portal_cage_in_progress, replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, uid, u.MainQuest.CurrentQuestFlowType, u.MainQuest.CurrentMainQuestRouteId, u.MainQuest.CurrentQuestSceneId, u.MainQuest.HeadQuestSceneId, boolToInt(u.MainQuest.IsReachedLastQuestScene), u.MainQuest.ProgressQuestSceneId, u.MainQuest.ProgressHeadQuestSceneId, u.MainQuest.ProgressQuestFlowType, u.MainQuest.MainQuestSeasonId, - u.MainQuest.LatestVersion, u.MainQuest.SavedCurrentQuestSceneId, u.MainQuest.SavedHeadQuestSceneId, + u.MainQuest.LatestVersion, + boolToInt(u.MainQuest.SavedContext.Active), + u.MainQuest.SavedContext.CurrentQuestSceneId, u.MainQuest.SavedContext.HeadQuestSceneId, + u.MainQuest.SavedContext.CurrentMainQuestRouteId, u.MainQuest.SavedContext.MainQuestSeasonId, + boolToInt(u.MainQuest.SavedContext.IsReachedLastQuestScene), + boolToInt(u.MainQuest.SavedContext.PortalCageInProgress), u.MainQuest.ReplayFlowCurrentQuestSceneId, u.MainQuest.ReplayFlowHeadQuestSceneId); err != nil { return err } @@ -208,6 +213,12 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error { return err } } + for k, v := range u.MainQuestSeasonRoutes { + if err := exec(`INSERT INTO user_main_quest_season_routes (user_id, main_quest_season_id, main_quest_route_id, latest_version) VALUES (?,?,?,?)`, + uid, k.MainQuestSeasonId, k.MainQuestRouteId, v.LatestVersion); err != nil { + return err + } + } for id, v := range u.QuestLimitContentStatus { if err := exec(`INSERT INTO user_quest_limit_content_status (user_id, limit_content_id, limit_content_quest_status_type, event_quest_chapter_id, latest_version) VALUES (?,?,?,?,?)`, uid, id, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion); err != nil { @@ -562,11 +573,16 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { } } if before.MainQuest != after.MainQuest { - if err := exec(`UPDATE user_main_quest SET current_quest_flow_type=?, current_main_quest_route_id=?, current_quest_scene_id=?, head_quest_scene_id=?, is_reached_last_quest_scene=?, progress_quest_scene_id=?, progress_head_quest_scene_id=?, progress_quest_flow_type=?, main_quest_season_id=?, latest_version=?, saved_current_quest_scene_id=?, saved_head_quest_scene_id=?, replay_flow_current_quest_scene_id=?, replay_flow_head_quest_scene_id=? WHERE user_id=?`, + if err := exec(`UPDATE user_main_quest SET current_quest_flow_type=?, current_main_quest_route_id=?, current_quest_scene_id=?, head_quest_scene_id=?, is_reached_last_quest_scene=?, progress_quest_scene_id=?, progress_head_quest_scene_id=?, progress_quest_flow_type=?, main_quest_season_id=?, latest_version=?, saved_ctx_active=?, saved_ctx_current_quest_scene_id=?, saved_ctx_head_quest_scene_id=?, saved_ctx_current_main_quest_route_id=?, saved_ctx_main_quest_season_id=?, saved_ctx_is_reached_last_quest_scene=?, saved_ctx_portal_cage_in_progress=?, replay_flow_current_quest_scene_id=?, replay_flow_head_quest_scene_id=? WHERE user_id=?`, after.MainQuest.CurrentQuestFlowType, after.MainQuest.CurrentMainQuestRouteId, after.MainQuest.CurrentQuestSceneId, after.MainQuest.HeadQuestSceneId, boolToInt(after.MainQuest.IsReachedLastQuestScene), after.MainQuest.ProgressQuestSceneId, after.MainQuest.ProgressHeadQuestSceneId, after.MainQuest.ProgressQuestFlowType, after.MainQuest.MainQuestSeasonId, - after.MainQuest.LatestVersion, after.MainQuest.SavedCurrentQuestSceneId, after.MainQuest.SavedHeadQuestSceneId, + after.MainQuest.LatestVersion, + boolToInt(after.MainQuest.SavedContext.Active), + after.MainQuest.SavedContext.CurrentQuestSceneId, after.MainQuest.SavedContext.HeadQuestSceneId, + after.MainQuest.SavedContext.CurrentMainQuestRouteId, after.MainQuest.SavedContext.MainQuestSeasonId, + boolToInt(after.MainQuest.SavedContext.IsReachedLastQuestScene), + boolToInt(after.MainQuest.SavedContext.PortalCageInProgress), after.MainQuest.ReplayFlowCurrentQuestSceneId, after.MainQuest.ReplayFlowHeadQuestSceneId, uid); err != nil { return err } @@ -734,6 +750,18 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { func(v store.SideStoryQuestProgress) []any { return []any{0, v.HeadSideStoryQuestSceneId, int32(v.SideStoryQuestStateType), v.LatestVersion} }, "side_story_quest_id, head_side_story_quest_scene_id, side_story_quest_state_type, latest_version") + + for k, v := range after.MainQuestSeasonRoutes { + if old, ok := before.MainQuestSeasonRoutes[k]; !ok || old != v { + exec(`INSERT OR REPLACE INTO user_main_quest_season_routes (user_id, main_quest_season_id, main_quest_route_id, latest_version) VALUES (?,?,?,?)`, + uid, k.MainQuestSeasonId, k.MainQuestRouteId, v.LatestVersion) + } + } + for k := range before.MainQuestSeasonRoutes { + if _, ok := after.MainQuestSeasonRoutes[k]; !ok { + exec(`DELETE FROM user_main_quest_season_routes WHERE user_id=? AND main_quest_season_id=? AND main_quest_route_id=?`, uid, k.MainQuestSeasonId, k.MainQuestRouteId) + } + } diffMapInt32(tx, uid, before.QuestLimitContentStatus, after.QuestLimitContentStatus, "user_quest_limit_content_status", "limit_content_id", func(v store.QuestLimitContentStatus) []any { return []any{0, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion} diff --git a/server/internal/store/sqlite/user.go b/server/internal/store/sqlite/user.go index b57cb27..499a6e4 100644 --- a/server/internal/store/sqlite/user.go +++ b/server/internal/store/sqlite/user.go @@ -109,6 +109,7 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error { "user_big_hunt_max_scores", "user_quest_limit_content_status", "user_side_story_quests", + "user_main_quest_season_routes", "user_missions", "user_quest_missions", "user_quests", diff --git a/server/internal/store/types.go b/server/internal/store/types.go index 13652c8..4ae1546 100644 --- a/server/internal/store/types.go +++ b/server/internal/store/types.go @@ -41,6 +41,7 @@ type UserState struct { LoginBonus UserLoginBonusState Tutorials map[int32]TutorialProgressState MainQuest MainQuestState + MainQuestSeasonRoutes map[SeasonRouteKey]SeasonRouteEntry EventQuest EventQuestState ExtraQuest ExtraQuestState SideStoryQuests map[int32]SideStoryQuestProgress @@ -153,6 +154,9 @@ func (u *UserState) EnsureMaps() { if u.SideStoryQuests == nil { u.SideStoryQuests = make(map[int32]SideStoryQuestProgress) } + if u.MainQuestSeasonRoutes == nil { + u.MainQuestSeasonRoutes = make(map[SeasonRouteKey]SeasonRouteEntry) + } if u.QuestLimitContentStatus == nil { u.QuestLimitContentStatus = make(map[int32]QuestLimitContentStatus) } @@ -510,12 +514,24 @@ type MainQuestState struct { MainQuestSeasonId int32 LatestVersion int64 - SavedCurrentQuestSceneId int32 - SavedHeadQuestSceneId int32 + SavedContext SavedQuestContext ReplayFlowCurrentQuestSceneId int32 ReplayFlowHeadQuestSceneId int32 } +// SavedQuestContext snapshots player state when entering a menu-replay (cleared +// quest started from the Main Quest List menu). On finish, every field is +// restored atomically so the player returns to the exact pre-replay state. +type SavedQuestContext struct { + Active bool + CurrentQuestSceneId int32 + HeadQuestSceneId int32 + CurrentMainQuestRouteId int32 + MainQuestSeasonId int32 + IsReachedLastQuestScene bool + PortalCageInProgress bool +} + type EventQuestState struct { CurrentEventQuestChapterId int32 CurrentQuestId int32 @@ -543,6 +559,17 @@ type SideStoryActiveProgress struct { LatestVersion int64 } +type SeasonRouteKey struct { + MainQuestSeasonId int32 + MainQuestRouteId int32 +} + +type SeasonRouteEntry struct { + MainQuestSeasonId int32 + MainQuestRouteId int32 + LatestVersion int64 +} + type QuestLimitContentStatus struct { LimitContentQuestStatusType int32 EventQuestChapterId int32 diff --git a/server/internal/userdata/changed_tables.go b/server/internal/userdata/changed_tables.go index 854a68e..85a9609 100644 --- a/server/internal/userdata/changed_tables.go +++ b/server/internal/userdata/changed_tables.go @@ -100,9 +100,11 @@ func ChangedTables(before, after *store.UserState) []string { add("IUserMainQuestFlowStatus") add("IUserMainQuestMainFlowStatus") add("IUserMainQuestProgressStatus") - add("IUserMainQuestSeasonRoute") add("IUserMainQuestReplayFlowStatus") } + if !mapsEqualStruct(before.MainQuestSeasonRoutes, after.MainQuestSeasonRoutes) { + add("IUserMainQuestSeasonRoute") + } if before.EventQuest != after.EventQuest { add("IUserEventQuestProgressStatus") } @@ -434,6 +436,8 @@ func keyFieldsForTable(table string) []string { return []string{"userId", "dokanId"} case "IUserSideStoryQuest": return []string{"userId", "sideStoryQuestId"} + case "IUserMainQuestSeasonRoute": + return []string{"userId", "mainQuestSeasonId", "mainQuestRouteId"} case "IUserQuestLimitContentStatus": return []string{"userId", "questId"} case "IUserBigHuntMaxScore": diff --git a/server/internal/userdata/proj_quest.go b/server/internal/userdata/proj_quest.go index 981d43f..ab751d3 100644 --- a/server/internal/userdata/proj_quest.go +++ b/server/internal/userdata/proj_quest.go @@ -98,12 +98,37 @@ func init() { return s }) register("IUserMainQuestSeasonRoute", func(user store.UserState) string { - s, _ := utils.EncodeJSONMaps(map[string]any{ - "userId": user.UserId, - "mainQuestSeasonId": user.MainQuest.MainQuestSeasonId, - "mainQuestRouteId": user.MainQuest.CurrentMainQuestRouteId, - "latestVersion": user.MainQuest.LatestVersion, + if len(user.MainQuestSeasonRoutes) == 0 { + // Fallback to current (season, route) for legacy saves with no history. + s, _ := utils.EncodeJSONMaps(map[string]any{ + "userId": user.UserId, + "mainQuestSeasonId": user.MainQuest.MainQuestSeasonId, + "mainQuestRouteId": user.MainQuest.CurrentMainQuestRouteId, + "latestVersion": user.MainQuest.LatestVersion, + }) + return s + } + keys := make([]store.SeasonRouteKey, 0, len(user.MainQuestSeasonRoutes)) + for k := range user.MainQuestSeasonRoutes { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].MainQuestSeasonId != keys[j].MainQuestSeasonId { + return keys[i].MainQuestSeasonId < keys[j].MainQuestSeasonId + } + return keys[i].MainQuestRouteId < keys[j].MainQuestRouteId }) + records := make([]map[string]any, 0, len(keys)) + for _, k := range keys { + e := user.MainQuestSeasonRoutes[k] + records = append(records, map[string]any{ + "userId": user.UserId, + "mainQuestSeasonId": e.MainQuestSeasonId, + "mainQuestRouteId": e.MainQuestRouteId, + "latestVersion": e.LatestVersion, + }) + } + s, _ := utils.EncodeJSONMaps(records...) return s }) register("IUserEventQuestProgressStatus", func(user store.UserState) string { diff --git a/server/migrations/20260507181003_add_main_quest_season_routes.sql b/server/migrations/20260507181003_add_main_quest_season_routes.sql new file mode 100644 index 0000000..c4f51a8 --- /dev/null +++ b/server/migrations/20260507181003_add_main_quest_season_routes.sql @@ -0,0 +1,19 @@ +-- +goose Up +CREATE TABLE user_main_quest_season_routes ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + main_quest_season_id INTEGER NOT NULL, + main_quest_route_id INTEGER NOT NULL, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, main_quest_season_id, main_quest_route_id) +); + +-- Backfill: seed each user's current (season, route) so existing saves immediately +-- have at least one entry. Players who progressed through prior seasons in our +-- server won't get historical entries — accept that for the immediate fix. +INSERT INTO user_main_quest_season_routes (user_id, main_quest_season_id, main_quest_route_id, latest_version) +SELECT user_id, main_quest_season_id, current_main_quest_route_id, latest_version +FROM user_main_quest +WHERE main_quest_season_id > 0 AND current_main_quest_route_id > 0; + +-- +goose Down +DROP TABLE IF EXISTS user_main_quest_season_routes; diff --git a/server/migrations/20260508094826_add_saved_quest_context.sql b/server/migrations/20260508094826_add_saved_quest_context.sql new file mode 100644 index 0000000..8c0ac18 --- /dev/null +++ b/server/migrations/20260508094826_add_saved_quest_context.sql @@ -0,0 +1,17 @@ +-- +goose Up +ALTER TABLE user_main_quest ADD COLUMN saved_ctx_active INTEGER NOT NULL DEFAULT 0; +ALTER TABLE user_main_quest ADD COLUMN saved_ctx_current_quest_scene_id INTEGER NOT NULL DEFAULT 0; +ALTER TABLE user_main_quest ADD COLUMN saved_ctx_head_quest_scene_id INTEGER NOT NULL DEFAULT 0; +ALTER TABLE user_main_quest ADD COLUMN saved_ctx_current_main_quest_route_id INTEGER NOT NULL DEFAULT 0; +ALTER TABLE user_main_quest ADD COLUMN saved_ctx_main_quest_season_id INTEGER NOT NULL DEFAULT 0; +ALTER TABLE user_main_quest ADD COLUMN saved_ctx_is_reached_last_quest_scene INTEGER NOT NULL DEFAULT 0; +ALTER TABLE user_main_quest ADD COLUMN saved_ctx_portal_cage_in_progress INTEGER NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE user_main_quest DROP COLUMN saved_ctx_active; +ALTER TABLE user_main_quest DROP COLUMN saved_ctx_current_quest_scene_id; +ALTER TABLE user_main_quest DROP COLUMN saved_ctx_head_quest_scene_id; +ALTER TABLE user_main_quest DROP COLUMN saved_ctx_current_main_quest_route_id; +ALTER TABLE user_main_quest DROP COLUMN saved_ctx_main_quest_season_id; +ALTER TABLE user_main_quest DROP COLUMN saved_ctx_is_reached_last_quest_scene; +ALTER TABLE user_main_quest DROP COLUMN saved_ctx_portal_cage_in_progress;