From 6c9e3c45f067ce7c207f33955768157953c16213 Mon Sep 17 00:00:00 2001 From: Ilya Groshev Date: Mon, 11 May 2026 20:21:55 +0300 Subject: [PATCH] Fix map replay flow and quest mission rewards --- server/internal/masterdata/quest.go | 46 ---------- server/internal/model/quest.go | 9 ++ server/internal/questflow/bighunt_quest.go | 2 +- server/internal/questflow/event_quest.go | 9 +- server/internal/questflow/extra_quest.go | 9 +- server/internal/questflow/quest.go | 73 ++++++++++----- server/internal/questflow/rewards.go | 90 +++++++++++-------- server/internal/questflow/scene.go | 86 ++++++++++++++---- server/internal/service/portalcage.go | 7 ++ server/internal/service/user.go | 20 +---- server/internal/store/sqlite/load.go | 6 -- server/internal/store/sqlite/save.go | 28 +----- server/internal/userdata/proj_quest.go | 3 + ...510060827_backfill_prior_season_routes.sql | 41 +++++++++ 14 files changed, 241 insertions(+), 188 deletions(-) create mode 100644 server/migrations/20260510060827_backfill_prior_season_routes.sql diff --git a/server/internal/masterdata/quest.go b/server/internal/masterdata/quest.go index 2181898..905b855 100644 --- a/server/internal/masterdata/quest.go +++ b/server/internal/masterdata/quest.go @@ -12,34 +12,6 @@ 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 @@ -62,7 +34,6 @@ 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 @@ -148,22 +119,6 @@ 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) @@ -600,7 +555,6 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) { TutorialUnlockConditions: tutorialUnlockConds, ChapterLastSceneByQuestId: chapterLastSceneByQuestId, SeasonIdByRouteId: seasonIdByRouteId, - OrderedSeasonRoutes: orderedSeasonRoutes, QuestsWithDifficulty: questsWithDifficulty, BattleOnlyTargetSceneByQuestId: battleOnlyTargetSceneByQuestId, diff --git a/server/internal/model/quest.go b/server/internal/model/quest.go index 81b8b01..9e15727 100644 --- a/server/internal/model/quest.go +++ b/server/internal/model/quest.go @@ -12,6 +12,15 @@ const ( QuestFlowTypeAnotherRouteReplayFlow QuestFlowType = 4 ) +// IsReplayQuestFlowType reports whether the flow type indicates an active +// replay session — either same-route REPLAY_FLOW or cross-route +// ANOTHER_ROUTE_REPLAY_FLOW. Mirrors the client's Story.IsReplayQuestFlowType +// predicate (dump.cs:768202). +func IsReplayQuestFlowType(t int32) bool { + return t == int32(QuestFlowTypeReplayFlow) || + t == int32(QuestFlowTypeAnotherRouteReplayFlow) +} + func (t QuestFlowType) String() string { switch t { case QuestFlowTypeUnknown: diff --git a/server/internal/questflow/bighunt_quest.go b/server/internal/questflow/bighunt_quest.go index 781fe5b..8c41d47 100644 --- a/server/internal/questflow/bighunt_quest.go +++ b/server/internal/questflow/bighunt_quest.go @@ -35,7 +35,7 @@ func (h *QuestHandler) HandleBigHuntQuestFinish(user *store.UserState, questId i outcome := h.evaluateFinishOutcome(user, questId) if !isRetired { - h.applyQuestVictory(user, questId, &outcome, nowMillis) + h.applyQuestVictory(user, questId, &outcome, nowMillis, false) } if isRetired && !isAnnihilated && quest.Stamina > 1 { diff --git a/server/internal/questflow/event_quest.go b/server/internal/questflow/event_quest.go index 424f735..2fad8ec 100644 --- a/server/internal/questflow/event_quest.go +++ b/server/internal/questflow/event_quest.go @@ -44,7 +44,7 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC outcome := h.evaluateFinishOutcome(user, questId) if !isRetired { - h.applyQuestVictory(user, questId, &outcome, nowMillis) + h.applyQuestVictory(user, questId, &outcome, nowMillis, false) } if isRetired && !isAnnihilated && quest.Stamina > 1 { @@ -72,8 +72,7 @@ func (h *QuestHandler) HandleEventQuestRestart(user *store.UserState, eventQuest } func (h *QuestHandler) HandleEventQuestSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) { - scene, ok := h.SceneById[questSceneId] - if !ok { + if _, ok := h.SceneById[questSceneId]; !ok { log.Printf("[HandleEventQuestSceneProgress] unknown sceneId=%d, skipping", questSceneId) return } @@ -84,8 +83,4 @@ func (h *QuestHandler) HandleEventQuestSceneProgress(user *store.UserState, ques } h.applySceneGrants(user, questSceneId, nowMillis) - - if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult { - h.clearQuestMissions(user, scene.QuestId, nowMillis) - } } diff --git a/server/internal/questflow/extra_quest.go b/server/internal/questflow/extra_quest.go index 60e4ac2..d7abc4d 100644 --- a/server/internal/questflow/extra_quest.go +++ b/server/internal/questflow/extra_quest.go @@ -42,7 +42,7 @@ func (h *QuestHandler) HandleExtraQuestFinish(user *store.UserState, questId int outcome := h.evaluateFinishOutcome(user, questId) if !isRetired { - h.applyQuestVictory(user, questId, &outcome, nowMillis) + h.applyQuestVictory(user, questId, &outcome, nowMillis, false) } if isRetired && !isAnnihilated && quest.Stamina > 1 { @@ -67,8 +67,7 @@ func (h *QuestHandler) HandleExtraQuestRestart(user *store.UserState, questId in } func (h *QuestHandler) HandleExtraQuestSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) { - scene, ok := h.SceneById[questSceneId] - if !ok { + if _, ok := h.SceneById[questSceneId]; !ok { log.Printf("[HandleExtraQuestSceneProgress] unknown sceneId=%d, skipping", questSceneId) return } @@ -79,8 +78,4 @@ func (h *QuestHandler) HandleExtraQuestSceneProgress(user *store.UserState, ques } h.applySceneGrants(user, questSceneId, nowMillis) - - if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult { - h.clearQuestMissions(user, scene.QuestId, nowMillis) - } } diff --git a/server/internal/questflow/quest.go b/server/internal/questflow/quest.go index 50824aa..32ce14d 100644 --- a/server/internal/questflow/quest.go +++ b/server/internal/questflow/quest.go @@ -78,13 +78,9 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i 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) + case isReplayFlow: + h.applyReplayStart(user, questId, isBattleOnly, nowMillis) + return } if isCleared { @@ -129,6 +125,27 @@ func snapshotMainQuestIfNeeded(user *store.UserState) { } } +// Preserve CurrentQuestFlowType when HandleReplayFlowSceneProgress already +// set it: replay-variant ids (30000+) aren't in RouteIdByQuestId. +func (h *QuestHandler) applyReplayStart(user *store.UserState, questId int32, isBattleOnly bool, nowMillis int64) { + flowType := h.replayFlowTypeFromQuestId(user, questId) + if model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType) { + flowType = model.QuestFlowType(user.MainQuest.CurrentQuestFlowType) + } + user.MainQuest.CurrentQuestFlowType = int32(flowType) + user.MainQuest.LatestVersion = nowMillis + + questState := user.Quests[questId] + questState.QuestStateType = model.UserQuestStateTypeActive + questState.LatestStartDatetime = nowMillis + user.Quests[questId] = questState + + log.Printf("[HandleQuestStart] replay quest=%d flowType=%s isBattleOnly=%v current=%d head=%d", + questId, flowType, isBattleOnly, + user.MainQuest.ReplayFlowCurrentQuestSceneId, + user.MainQuest.ReplayFlowHeadQuestSceneId) +} + func (h *QuestHandler) menuPickSceneId(questId int32, isBattleOnly bool) int32 { if isBattleOnly { if v, ok := h.BattleOnlyTargetSceneIdFor(questId); ok { @@ -141,14 +158,24 @@ func (h *QuestHandler) menuPickSceneId(questId int32, isBattleOnly bool) int32 { return 0 } -func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, outcome *FinishOutcome, nowMillis int64) { +func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, outcome *FinishOutcome, nowMillis int64, wasReplay bool) { questState := user.Quests[questId] if !questState.IsRewardGranted { - h.applyQuestRewards(user, questId, nowMillis) - outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds, - h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeHalfResult, nowMillis)...) - outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds, - h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeFullResult, nowMillis)...) + h.applyExpAndGoldRewards(user, questId, nowMillis) + if !wasReplay { + h.applyFirstClearItemRewards(user, questId, nowMillis) + outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds, + h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeHalfResult, nowMillis)...) + outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds, + h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeFullResult, nowMillis)...) + } + + for _, r := range outcome.MissionClearRewards { + h.applyRewardPossession(user, r.PossessionType, r.PossessionId, r.Count, nowMillis) + } + for _, r := range outcome.MissionClearCompleteRewards { + h.applyRewardPossession(user, r.PossessionType, r.PossessionId, r.Count, nowMillis) + } questState.IsRewardGranted = true } for _, drop := range outcome.DropRewards { @@ -197,13 +224,13 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i h.initQuestState(user, questId) outcome := h.evaluateFinishOutcome(user, questId) - wasReplay := user.MainQuest.CurrentQuestFlowType == int32(model.QuestFlowTypeReplayFlow) + wasReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType) wasMenuReplay := user.MainQuest.SavedContext.Active if !isRetired { - h.applyQuestVictory(user, questId, &outcome, nowMillis) + h.applyQuestVictory(user, questId, &outcome, nowMillis, wasReplay) - if isMainQuestPlayable(quest) && !wasReplay && !wasMenuReplay { + if isMainQuestPlayable(quest) && !wasMenuReplay { lastSceneId := h.getLastMainFlowSceneId(questId) h.advanceMainFlowScene(user, questId, lastSceneId) } @@ -229,8 +256,12 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i user.MainQuest.ProgressQuestSceneId = 0 user.MainQuest.ProgressHeadQuestSceneId = 0 - user.MainQuest.ProgressQuestFlowType = 0 - user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow) + if !wasReplay { + // Keep replay flow types on replay finish so the client's + // Story.ApplyNewestPlayingScene keeps _isReplayed=true (popup result UI). + user.MainQuest.ProgressQuestFlowType = 0 + user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeUnknown) + } if wasMenuReplay { ctx := user.MainQuest.SavedContext @@ -248,12 +279,6 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i ctx.CurrentQuestSceneId, ctx.HeadQuestSceneId, ctx.PortalCageInProgress) } - if wasReplay { - user.MainQuest.ReplayFlowCurrentQuestSceneId = 0 - user.MainQuest.ReplayFlowHeadQuestSceneId = 0 - log.Printf("[HandleQuestFinish] replay flow ended for quest %d", questId) - } - h.clearQuestMissions(user, questId, nowMillis) return outcome diff --git a/server/internal/questflow/rewards.go b/server/internal/questflow/rewards.go index 7a70bba..090e934 100644 --- a/server/internal/questflow/rewards.go +++ b/server/internal/questflow/rewards.go @@ -51,7 +51,9 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3 panic(fmt.Sprintf("unknown questId=%d for evaluateFinishOutcome", questId)) } - if !questState.IsRewardGranted { + isReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType) + + if !questState.IsRewardGranted && !isReplay { rewardGroupId := h.firstClearRewardGroupId(user, questDef) for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] { outcome.FirstClearRewards = append(outcome.FirstClearRewards, RewardGrant{ @@ -62,7 +64,7 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3 } } - if user.MainQuest.CurrentQuestFlowType == int32(model.QuestFlowTypeReplayFlow) && questDef.QuestReplayFlowRewardGroupId > 0 { + if isReplay && questDef.QuestReplayFlowRewardGroupId > 0 { for _, reward := range h.ReplayFlowRewardsByGroupId[questDef.QuestReplayFlowRewardGroupId] { outcome.ReplayFlowFirstClearRewards = append(outcome.ReplayFlowFirstClearRewards, RewardGrant{ PossessionType: model.PossessionType(reward.PossessionType), @@ -72,48 +74,53 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3 } } - pendingClearCount := 0 - regularMissionCount := 0 - for _, questMissionId := range h.MissionIdsByQuestId[questId] { - missionDef, ok := h.MissionById[questMissionId] - if !ok || model.QuestMissionConditionType(missionDef.QuestMissionConditionType) == model.QuestMissionConditionTypeComplete { - continue - } - regularMissionCount++ - - key := store.QuestMissionKey{QuestId: questId, QuestMissionId: questMissionId} - mission := user.QuestMissions[key] - - if !mission.IsClear { - pendingClearCount++ - outcome.MissionClearRewards = appendMissionRewards( - outcome.MissionClearRewards, - h.MissionRewardsByMissionId[missionDef.QuestMissionRewardId], - ) - } - } - - priorClearCount := regularMissionCount - pendingClearCount - // On our server every mission auto-clears, so priorClearCount + pendingClearCount - // always equals regularMissionCount. The two-variable form is kept to mirror the - // original game's intent where individual missions could fail their conditions. - allRegularWillClear := regularMissionCount > 0 && (priorClearCount+pendingClearCount) == regularMissionCount - if allRegularWillClear { + // Mission rewards / BigWin are first-clear concepts. Reference + // IUserQuestMissionTable has no rows for replay-variant ids (30000+): + // the popup is empty on replay in the original game. + if !isReplay { + pendingClearCount := 0 + regularMissionCount := 0 for _, questMissionId := range h.MissionIdsByQuestId[questId] { missionDef, ok := h.MissionById[questMissionId] - if !ok || model.QuestMissionConditionType(missionDef.QuestMissionConditionType) != model.QuestMissionConditionTypeComplete { + if !ok || model.QuestMissionConditionType(missionDef.QuestMissionConditionType) == model.QuestMissionConditionTypeComplete { continue } + regularMissionCount++ + key := store.QuestMissionKey{QuestId: questId, QuestMissionId: questMissionId} - if !user.QuestMissions[key].IsClear { - outcome.MissionClearCompleteRewards = appendMissionRewards( - outcome.MissionClearCompleteRewards, + mission := user.QuestMissions[key] + + if !mission.IsClear { + pendingClearCount++ + outcome.MissionClearRewards = appendMissionRewards( + outcome.MissionClearRewards, h.MissionRewardsByMissionId[missionDef.QuestMissionRewardId], ) - outcome.BigWinClearedQuestMissionIds = append(outcome.BigWinClearedQuestMissionIds, questMissionId) } } - outcome.IsBigWin = len(outcome.BigWinClearedQuestMissionIds) > 0 + + priorClearCount := regularMissionCount - pendingClearCount + // On our server every mission auto-clears, so priorClearCount + pendingClearCount + // always equals regularMissionCount. The two-variable form is kept to mirror the + // original game's intent where individual missions could fail their conditions. + allRegularWillClear := regularMissionCount > 0 && (priorClearCount+pendingClearCount) == regularMissionCount + if allRegularWillClear { + for _, questMissionId := range h.MissionIdsByQuestId[questId] { + missionDef, ok := h.MissionById[questMissionId] + if !ok || model.QuestMissionConditionType(missionDef.QuestMissionConditionType) != model.QuestMissionConditionTypeComplete { + continue + } + key := store.QuestMissionKey{QuestId: questId, QuestMissionId: questMissionId} + if !user.QuestMissions[key].IsClear { + outcome.MissionClearCompleteRewards = appendMissionRewards( + outcome.MissionClearCompleteRewards, + h.MissionRewardsByMissionId[missionDef.QuestMissionRewardId], + ) + outcome.BigWinClearedQuestMissionIds = append(outcome.BigWinClearedQuestMissionIds, questMissionId) + } + } + outcome.IsBigWin = len(outcome.BigWinClearedQuestMissionIds) > 0 + } } outcome.DropRewards = h.computeDropRewards(questDef) @@ -240,7 +247,7 @@ func (h *QuestHandler) resolveDeckUnits(user *store.UserState, questId int32) (c return costumeUuids, characterIds } -func (h *QuestHandler) applyQuestRewards(user *store.UserState, questId int32, nowMillis int64) { +func (h *QuestHandler) applyExpAndGoldRewards(user *store.UserState, questId int32, nowMillis int64) { questDef, ok := h.QuestById[questId] if !ok { return @@ -252,13 +259,24 @@ func (h *QuestHandler) applyQuestRewards(user *store.UserState, questId int32, n user.ConsumableItems[h.Config.ConsumableItemIdForGold] += questDef.Gold log.Printf("[applyQuestRewards] questId=%d gold: +%d -> total=%d", questId, questDef.Gold, user.ConsumableItems[h.Config.ConsumableItemIdForGold]) } +} +func (h *QuestHandler) applyFirstClearItemRewards(user *store.UserState, questId int32, nowMillis int64) { + questDef, ok := h.QuestById[questId] + if !ok { + return + } rewardGroupId := h.firstClearRewardGroupId(user, questDef) for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] { h.applyRewardPossession(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis) } } +func (h *QuestHandler) applyQuestRewards(user *store.UserState, questId int32, nowMillis int64) { + h.applyExpAndGoldRewards(user, questId, nowMillis) + h.applyFirstClearItemRewards(user, questId, nowMillis) +} + func (h *QuestHandler) applyRewardPossession(user *store.UserState, possType model.PossessionType, possId, count int32, nowMillis int64) { h.Granter.GrantFull(user, possType, possId, count, nowMillis) } diff --git a/server/internal/questflow/scene.go b/server/internal/questflow/scene.go index 36f14b8..46c5221 100644 --- a/server/internal/questflow/scene.go +++ b/server/internal/questflow/scene.go @@ -1,5 +1,11 @@ package questflow +// MainQuest scene-field families mirror three client entity tables: +// +// MainFlow* — EntityIUserMainQuestMainFlowStatus (#11443) +// Progress* — EntityIUserMainQuestProgressStatus (#11444) +// ReplayFlow* — EntityIUserMainQuestReplayFlowStatus (#11445) + import ( "fmt" "log" @@ -45,10 +51,8 @@ func (h *QuestHandler) advanceMainFlowScene(user *store.UserState, questId, scen } } -// 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). +// Backs IUserMainQuestSeasonRoute: the client needs the history to load +// scene metadata when cage menu-replay jumps to older chapters. func RecordSeasonRoute(user *store.UserState, seasonId, routeId int32, nowMillis int64) { if seasonId <= 0 || routeId <= 0 { return @@ -133,11 +137,48 @@ func (h *QuestHandler) getChapterLastSceneId(questId int32) int32 { func (h *QuestHandler) HandleReplayFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) { user.MainQuest.ReplayFlowCurrentQuestSceneId = questSceneId - if user.MainQuest.ReplayFlowHeadQuestSceneId == 0 || h.isSceneAhead(questSceneId, user.MainQuest.ReplayFlowHeadQuestSceneId) { - user.MainQuest.ReplayFlowHeadQuestSceneId = questSceneId - } + user.MainQuest.ReplayFlowHeadQuestSceneId = questSceneId + + user.PortalCageStatus.IsCurrentProgress = false + user.PortalCageStatus.LatestVersion = nowMillis + + flowType := h.replayFlowType(user, questSceneId) + user.MainQuest.CurrentQuestFlowType = int32(flowType) user.MainQuest.LatestVersion = nowMillis - log.Printf("[HandleReplayFlowSceneProgress] sceneId=%d replayHead=%d", questSceneId, user.MainQuest.ReplayFlowHeadQuestSceneId) + log.Printf("[HandleReplayFlowSceneProgress] sceneId=%d flowType=%s", questSceneId, flowType) +} + +func (h *QuestHandler) replayFlowType(user *store.UserState, questSceneId int32) model.QuestFlowType { + scene, ok := h.SceneById[questSceneId] + if !ok { + return model.QuestFlowTypeReplayFlow + } + routeId, ok := h.RouteIdByQuestId[scene.QuestId] + if !ok { + return model.QuestFlowTypeReplayFlow + } + return h.replayFlowTypeForRoute(user, routeId) +} + +func (h *QuestHandler) replayFlowTypeForRoute(user *store.UserState, routeId int32) model.QuestFlowType { + seasonId, ok := h.SeasonIdByRouteId[routeId] + if !ok { + return model.QuestFlowTypeReplayFlow + } + for key, entry := range user.MainQuestSeasonRoutes { + if key.MainQuestSeasonId == seasonId && entry.MainQuestRouteId != routeId { + return model.QuestFlowTypeAnotherRouteReplayFlow + } + } + return model.QuestFlowTypeReplayFlow +} + +func (h *QuestHandler) replayFlowTypeFromQuestId(user *store.UserState, questId int32) model.QuestFlowType { + routeId, ok := h.RouteIdByQuestId[questId] + if !ok { + return model.QuestFlowTypeReplayFlow + } + return h.replayFlowTypeForRoute(user, routeId) } func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, questSceneId int32) { @@ -153,22 +194,27 @@ func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, quest 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()) + // Skip if the previous quest is playable — it has its own FinishMainQuest; + // chain-finalizing here would double-increment ClearCount. + if prevQuest, ok := h.QuestById[prevScene.QuestId]; ok && !isMainQuestPlayable(prevQuest) { + h.finalizeChainPreviousQuest(user, prevScene.QuestId, gametime.NowMillis()) + } } } - if isMainQuestPlayable(quest) { - if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult { - nowMillis := gametime.NowMillis() - h.clearQuestMissions(user, quest.QuestId, nowMillis) - } + isReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType) + if isMainQuestPlayable(quest) { user.MainQuest.ProgressQuestSceneId = questSceneId if h.isSceneAhead(questSceneId, user.MainQuest.ProgressHeadQuestSceneId) { user.MainQuest.ProgressHeadQuestSceneId = questSceneId } - user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeSubFlow) - user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeSubFlow) + if isReplay { + user.MainQuest.ProgressQuestFlowType = user.MainQuest.CurrentQuestFlowType + } else { + user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeSubFlow) + user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeSubFlow) + } } else { user.MainQuest.CurrentQuestSceneId = questSceneId if h.isSceneAhead(questSceneId, user.MainQuest.HeadQuestSceneId) { @@ -177,4 +223,12 @@ func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, quest lastSceneId := h.getChapterLastSceneId(quest.QuestId) user.MainQuest.IsReachedLastQuestScene = questSceneId == lastSceneId } + + if isReplay { + user.MainQuest.ReplayFlowCurrentQuestSceneId = questSceneId + if h.isSceneAhead(questSceneId, user.MainQuest.ReplayFlowHeadQuestSceneId) { + user.MainQuest.ReplayFlowHeadQuestSceneId = questSceneId + } + user.MainQuest.LatestVersion = gametime.NowMillis() + } } diff --git a/server/internal/service/portalcage.go b/server/internal/service/portalcage.go index 18b17d7..3d63f27 100644 --- a/server/internal/service/portalcage.go +++ b/server/internal/service/portalcage.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/store" ) @@ -27,6 +28,12 @@ func (s *PortalCageServiceServer) UpdatePortalCageSceneProgress(ctx context.Cont now := gametime.NowMillis() user.PortalCageStatus.IsCurrentProgress = true user.PortalCageStatus.LatestVersion = now + // Mama's Room ends any active replay — flip flow type to MainFlow; + // ReplayFlow* stay sticky (matches original userdata snapshot). + if user.MainQuest.CurrentQuestFlowType != int32(model.QuestFlowTypeMainFlow) { + user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow) + user.MainQuest.LatestVersion = now + } }) return &pb.UpdatePortalCageSceneProgressResponse{}, nil } diff --git a/server/internal/service/user.go b/server/internal/service/user.go index 065bb96..ab77684 100644 --- a/server/internal/service/user.go +++ b/server/internal/service/user.go @@ -19,7 +19,6 @@ 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" ) @@ -96,24 +95,7 @@ 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) { - 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) - } - } - } + user.GameStartDatetime = gametime.NowMillis() }) return &pb.GameStartResponse{}, nil diff --git a/server/internal/store/sqlite/load.go b/server/internal/store/sqlite/load.go index 4fe982b..700ccb5 100644 --- a/server/internal/store/sqlite/load.go +++ b/server/internal/store/sqlite/load.go @@ -537,7 +537,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) { u.DokanConfirmed[id] = true }) - // Gifts queryRows(db, `SELECT user_gift_uuid, is_received, possession_type, possession_id, count, grant_datetime, description_gift_text_id, equipment_data, expiration_datetime, received_datetime FROM user_gifts WHERE user_id=?`, uid, func(rows *sql.Rows) { @@ -560,7 +559,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) { } }) - // Gacha converted medals queryRows(db, `SELECT consumable_item_id, count FROM user_gacha_converted_medals WHERE user_id=? ORDER BY ordinal`, uid, func(rows *sql.Rows) { var v store.ConsumableItemState @@ -568,7 +566,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) { u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession = append(u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession, v) }) - // Gacha banners queryRows(db, `SELECT gacha_id, medal_count, step_number, loop_count, draw_count, box_number FROM user_gacha_banners WHERE user_id=?`, uid, func(rows *sql.Rows) { var v store.GachaBannerState @@ -586,7 +583,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) { } }) - // Character boards queryRows(db, `SELECT character_board_id, panel_release_bit1, panel_release_bit2, panel_release_bit3, panel_release_bit4, latest_version FROM user_character_boards WHERE user_id=?`, uid, func(rows *sql.Rows) { @@ -642,7 +638,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) { u.ShopReplaceableLineup[v.SlotNumber] = v }) - // Gimmick tables queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, is_gimmick_cleared, start_datetime, latest_version FROM user_gimmick_progress WHERE user_id=?`, uid, func(rows *sql.Rows) { @@ -685,7 +680,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) { u.Gimmick.Unlocks[v.Key] = v }) - // Big hunt maps queryRows(db, `SELECT big_hunt_boss_id, max_score, max_score_update_datetime, latest_version FROM user_big_hunt_max_scores 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 dbbf2f5..47aa82a 100644 --- a/server/internal/store/sqlite/save.go +++ b/server/internal/store/sqlite/save.go @@ -15,8 +15,7 @@ func boolToInt(b bool) int { return 0 } -// writeUserState inserts all child table rows for a newly created user. -// The users row must already exist. +// Precondition: the users row must already exist. func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error { exec := func(query string, args ...any) error { _, err := tx.Exec(query, args...) @@ -123,7 +122,6 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error { return err } - // Map tables for _, v := range u.Characters { if err := exec(`INSERT INTO user_characters (user_id, character_id, level, exp, latest_version) VALUES (?,?,?,?,?)`, uid, v.CharacterId, v.Level, v.Exp, v.LatestVersion); err != nil { @@ -507,18 +505,13 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error { return nil } -// diffAndSave compares before/after UserState and writes only changed rows. -// For 1:1 tables, it UPDATEs if any field changed. -// For map tables, it uses INSERT OR REPLACE for added/modified entries and DELETE for removed ones. -// For slice-based data (gifts, medals, deck sub-weapons/parts, weapon skills/abilities), -// it does DELETE-all then INSERT-all for simplicity. +// 1:1 tables update on field-change; maps INSERT OR REPLACE + DELETE; slice tables DELETE-all then INSERT-all. func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { exec := func(query string, args ...any) error { _, err := tx.Exec(query, args...) return err } - // users table if before.PlayerId != after.PlayerId || before.OsType != after.OsType || before.PlatformType != after.PlatformType || before.UserRestrictionType != after.UserRestrictionType || before.RegisterDatetime != after.RegisterDatetime || before.GameStartDatetime != after.GameStartDatetime || before.LatestVersion != after.LatestVersion || @@ -653,7 +646,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { } } - // Gacha scalar if before.Gacha.RewardAvailable != after.Gacha.RewardAvailable || before.Gacha.TodaysCurrentDrawCount != after.Gacha.TodaysCurrentDrawCount || before.Gacha.DailyMaxCount != after.Gacha.DailyMaxCount || before.Gacha.LastRewardDrawDate != after.Gacha.LastRewardDrawDate { var obtainItemId, obtainCount sql.NullInt64 @@ -668,7 +660,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { } } - // Map tables — use generic diff helpers diffMapInt32(tx, uid, before.Characters, after.Characters, "user_characters", "character_id", func(v store.CharacterState) []any { return []any{v.CharacterId, v.Level, v.Exp, v.LatestVersion} }, "character_id, level, exp, latest_version") @@ -693,7 +684,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { return []any{v.UserDeckCharacterUuid, v.UserCostumeUuid, v.MainUserWeaponUuid, v.UserCompanionUuid, v.Power, v.UserThoughtUuid, v.DressupCostumeId, v.LatestVersion} }, "user_deck_character_uuid, user_costume_uuid, main_user_weapon_uuid, user_companion_uuid, power, user_thought_uuid, dressup_costume_id, latest_version") - // Decks (composite key) for k, v := range after.Decks { if old, ok := before.Decks[k]; !ok || old != v { exec(fmt.Sprintf(`INSERT OR REPLACE INTO user_decks (user_id, deck_type, user_deck_number, user_deck_character_uuid01, user_deck_character_uuid02, user_deck_character_uuid03, name, power, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`), @@ -706,7 +696,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { } } - // Slice-based tables: delete all + reinsert replaceSliceTable(tx, uid, "user_deck_sub_weapons", after.DeckSubWeapons, func(key string, uuids []string) { for i, uuid := range uuids { exec(`INSERT INTO user_deck_sub_weapons (user_id, user_deck_character_uuid, ordinal, user_weapon_uuid) VALUES (?,?,?,?)`, uid, key, i, uuid) @@ -723,7 +712,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { return []any{v.QuestId, int32(v.QuestStateType), boolToInt(v.IsBattleOnly), v.UserDeckNumber, v.LatestStartDatetime, v.ClearCount, v.DailyClearCount, v.LastClearDatetime, v.ShortestClearFrames, boolToInt(v.IsRewardGranted), v.LatestVersion} }, "quest_id, quest_state_type, is_battle_only, user_deck_number, latest_start_datetime, clear_count, daily_clear_count, last_clear_datetime, shortest_clear_frames, is_reward_granted, latest_version") - // Quest missions (composite key) for k, v := range after.QuestMissions { if old, ok := before.QuestMissions[k]; !ok || old != v { exec(`INSERT OR REPLACE INTO user_quest_missions (user_id, quest_id, quest_mission_id, progress_value, is_clear, latest_clear_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`, @@ -776,7 +764,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { return []any{v.WeaponId, v.MaxLevel, v.MaxLimitBreakCount, v.FirstAcquisitionDatetime, v.LatestVersion} }, "weapon_id, max_level, max_limit_break_count, first_acquisition_datetime, latest_version") - // Weapon skills/abilities: slice-based, delete+reinsert exec(`DELETE FROM user_weapon_skills WHERE user_id=?`, uid) for _, skills := range after.WeaponSkills { for _, v := range skills { @@ -798,7 +785,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { return []any{v.UserCostumeUuid, v.Level, v.AcquisitionDatetime, v.LatestVersion} }, "user_costume_uuid, level, acquisition_datetime, latest_version") - // Costume awaken status ups (composite key) for k, v := range after.CostumeAwakenStatusUps { if old, ok := before.CostumeAwakenStatusUps[k]; !ok || old != v { exec(`INSERT OR REPLACE INTO user_costume_awaken_status_ups (user_id, user_costume_uuid, status_calculation_type, hp, attack, vitality, agility, critical_ratio, critical_attack, latest_version) VALUES (?,?,?,?,?,?,?,?,?,?)`, @@ -854,7 +840,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { } } - // Deck type notes (key is model.DeckType which is int32-based) for k, v := range after.DeckTypeNotes { if old, ok := before.DeckTypeNotes[k]; !ok || old != v { exec(`INSERT OR REPLACE INTO user_deck_type_notes (user_id, deck_type, max_deck_power, latest_version) VALUES (?,?,?,?)`, @@ -888,7 +873,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { diffTimestampMap(tx, uid, before.DrawnOmikuji, after.DrawnOmikuji, "user_drawn_omikuji", "omikuji_id") diffBoolMap(tx, uid, before.DokanConfirmed, after.DokanConfirmed, "user_dokan_confirmed", "dokan_id") - // Gifts: delete all + reinsert exec(`DELETE FROM user_gifts WHERE user_id=?`, uid) for _, g := range after.Gifts.NotReceived { var expDt sql.NullInt64 @@ -904,19 +888,16 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { uid, uuid, g.GiftCommon.PossessionType, g.GiftCommon.PossessionId, g.GiftCommon.Count, g.GiftCommon.GrantDatetime, g.GiftCommon.DescriptionGiftTextId, g.GiftCommon.EquipmentData, g.ReceivedDatetime) } - // Gacha converted medals: delete+reinsert exec(`DELETE FROM user_gacha_converted_medals WHERE user_id=?`, uid) for i, v := range after.Gacha.ConvertedGachaMedal.ConvertedMedalPossession { exec(`INSERT INTO user_gacha_converted_medals (user_id, ordinal, consumable_item_id, count) VALUES (?,?,?,?)`, uid, i, v.ConsumableItemId, v.Count) } - // Gacha banners for id, v := range after.Gacha.BannerStates { if old, ok := before.Gacha.BannerStates[id]; !ok || old.MedalCount != v.MedalCount || old.StepNumber != v.StepNumber || old.LoopCount != v.LoopCount || old.DrawCount != v.DrawCount || old.BoxNumber != v.BoxNumber { exec(`INSERT OR REPLACE INTO user_gacha_banners (user_id, gacha_id, medal_count, step_number, loop_count, draw_count, box_number) VALUES (?,?,?,?,?,?,?)`, uid, v.GachaId, v.MedalCount, v.StepNumber, v.LoopCount, v.DrawCount, v.BoxNumber) } - // Box drew counts: always delete+reinsert for this gacha exec(`DELETE FROM user_gacha_banner_box_drew_counts WHERE user_id=? AND gacha_id=?`, uid, id) for itemId, count := range v.BoxDrewCounts { exec(`INSERT INTO user_gacha_banner_box_drew_counts (user_id, gacha_id, box_item_id, count) VALUES (?,?,?,?)`, uid, id, itemId, count) @@ -934,7 +915,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { return []any{v.CharacterBoardId, v.PanelReleaseBit1, v.PanelReleaseBit2, v.PanelReleaseBit3, v.PanelReleaseBit4, v.LatestVersion} }, "character_board_id, panel_release_bit1, panel_release_bit2, panel_release_bit3, panel_release_bit4, latest_version") - // Character board abilities (composite key) for k, v := range after.CharacterBoardAbilities { if old, ok := before.CharacterBoardAbilities[k]; !ok || old != v { exec(`INSERT OR REPLACE INTO user_character_board_abilities (user_id, character_id, ability_id, level, latest_version) VALUES (?,?,?,?,?)`, @@ -947,7 +927,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { } } - // Character board status ups (composite key) for k, v := range after.CharacterBoardStatusUps { if old, ok := before.CharacterBoardStatusUps[k]; !ok || old != v { exec(`INSERT OR REPLACE INTO user_character_board_status_ups (user_id, character_id, status_calculation_type, hp, attack, vitality, agility, critical_ratio, critical_attack, latest_version) VALUES (?,?,?,?,?,?,?,?,?,?)`, @@ -980,7 +959,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { }, "slot_number, shop_item_id, latest_version") - // Gimmick tables (composite keys) for k, v := range after.Gimmick.Progress { if old, ok := before.Gimmick.Progress[k]; !ok || old != v { exec(`INSERT OR REPLACE INTO user_gimmick_progress (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, is_gimmick_cleared, start_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`, @@ -1030,7 +1008,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { } } - // Big hunt maps diffMapInt32(tx, uid, before.BigHuntMaxScores, after.BigHuntMaxScores, "user_big_hunt_max_scores", "big_hunt_boss_id", func(v store.BigHuntMaxScore) []any { return []any{0, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion} @@ -1079,7 +1056,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { return nil } -// Generic diff helpers for map tables with int32 keys func diffMapInt32[V comparable](tx *sql.Tx, uid int64, before, after map[int32]V, table, keyCol string, vals func(V) []any, cols string) { for k, v := range after { if old, ok := before[k]; !ok || old != v { diff --git a/server/internal/userdata/proj_quest.go b/server/internal/userdata/proj_quest.go index ab751d3..93f536a 100644 --- a/server/internal/userdata/proj_quest.go +++ b/server/internal/userdata/proj_quest.go @@ -153,6 +153,9 @@ func init() { return s }) register("IUserMainQuestReplayFlowStatus", func(user store.UserState) string { + if user.MainQuest.ReplayFlowCurrentQuestSceneId == 0 && user.MainQuest.ReplayFlowHeadQuestSceneId == 0 { + return "[]" + } s, _ := utils.EncodeJSONMaps(map[string]any{ "userId": user.UserId, "currentHeadQuestSceneId": user.MainQuest.ReplayFlowHeadQuestSceneId, diff --git a/server/migrations/20260510060827_backfill_prior_season_routes.sql b/server/migrations/20260510060827_backfill_prior_season_routes.sql new file mode 100644 index 0000000..4519816 --- /dev/null +++ b/server/migrations/20260510060827_backfill_prior_season_routes.sql @@ -0,0 +1,41 @@ +-- +goose Up +-- For each user past season 2 with no season-2 row yet, infer which sun/moon +-- route they actually played from their cleared quests in user_quests, and +-- insert the matching (season=2, route=X) row. Heals players who progressed +-- past season 2 before per-scene RecordSeasonRoute existed (commit 9a2cc92). +-- +-- Quest-ID ranges per route (from master_data/EntityMMainQuestSequenceTable.json): +-- Route 2 (sun/moon variant A): quests 301..370 +-- Route 3 (sun/moon variant B): quests 401..470 +-- +-- The NOT EXISTS clause matches on (user, season) regardless of route, so any +-- existing real RecordSeasonRoute write is preserved verbatim. + +-- Rule 1: user has cleared a route-3 quest -> they picked variant B. +INSERT INTO user_main_quest_season_routes (user_id, main_quest_season_id, main_quest_route_id, latest_version) +SELECT umq.user_id, 2, 3, umq.latest_version +FROM user_main_quest umq +WHERE umq.main_quest_season_id > 2 + AND NOT EXISTS ( + SELECT 1 FROM user_main_quest_season_routes r + WHERE r.user_id = umq.user_id AND r.main_quest_season_id = 2 + ) + AND EXISTS ( + SELECT 1 FROM user_quests q + WHERE q.user_id = umq.user_id AND q.clear_count > 0 + AND q.quest_id BETWEEN 401 AND 470 + ); + +-- Rule 2: otherwise insert route 2 (covers users who picked variant A and +-- users with no observable route-2/route-3 history -- last-resort default). +INSERT INTO user_main_quest_season_routes (user_id, main_quest_season_id, main_quest_route_id, latest_version) +SELECT umq.user_id, 2, 2, umq.latest_version +FROM user_main_quest umq +WHERE umq.main_quest_season_id > 2 + AND NOT EXISTS ( + SELECT 1 FROM user_main_quest_season_routes r + WHERE r.user_id = umq.user_id AND r.main_quest_season_id = 2 + ); + +-- +goose Down +SELECT 1;