Fix Main Quests replay and weapon awaken level cap
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled

This commit is contained in:
Ilya Groshev
2026-05-09 17:18:48 +03:00
parent 60e0402525
commit 9a2cc92a6f
16 changed files with 440 additions and 65 deletions
+1 -1
View File
@@ -88,7 +88,7 @@ func registerServices(
pubPort, _ := strconv.Atoi(pubPortStr) pubPort, _ := strconv.Atoi(pubPortStr)
pb.RegisterBannerServiceServer(srv, service.NewBannerServiceServer(holder)) 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.RegisterBattleServiceServer(srv, service.NewBattleServiceServer(userStore, userStore))
pb.RegisterConfigServiceServer(srv, service.NewConfigServiceServer(pubHost, int32(pubPort), octoURL)) pb.RegisterConfigServiceServer(srv, service.NewConfigServiceServer(pubHost, int32(pubPort), octoURL))
pb.RegisterDataServiceServer(srv, service.NewDataServiceServer(userStore, userStore)) pb.RegisterDataServiceServer(srv, service.NewDataServiceServer(userStore, userStore))
+83
View File
@@ -12,6 +12,34 @@ type BattleDropInfo struct {
BattleDropCategoryId int32 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 { type QuestCatalog struct {
SceneById map[int32]EntityMQuestScene SceneById map[int32]EntityMQuestScene
MissionById map[int32]EntityMQuestMission MissionById map[int32]EntityMQuestMission
@@ -34,6 +62,9 @@ type QuestCatalog struct {
TutorialUnlockConditions []EntityMTutorialUnlockCondition TutorialUnlockConditions []EntityMTutorialUnlockCondition
ChapterLastSceneByQuestId map[int32]int32 ChapterLastSceneByQuestId map[int32]int32
SeasonIdByRouteId 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 UserExpThresholds []int32
CharacterExpThresholds []int32 CharacterExpThresholds []int32
@@ -117,6 +148,22 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
seasonIdByRouteId[r.MainQuestRouteId] = r.MainQuestSeasonId 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") firstClearSwitches, err := utils.ReadTable[EntityMQuestFirstClearRewardSwitch]("m_quest_first_clear_reward_switch")
if err != nil { if err != nil {
return nil, fmt.Errorf("load quest first clear reward switch table: %w", err) 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) 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() paramMapRows, err := LoadParameterMap()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -529,6 +600,9 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
TutorialUnlockConditions: tutorialUnlockConds, TutorialUnlockConditions: tutorialUnlockConds,
ChapterLastSceneByQuestId: chapterLastSceneByQuestId, ChapterLastSceneByQuestId: chapterLastSceneByQuestId,
SeasonIdByRouteId: seasonIdByRouteId, SeasonIdByRouteId: seasonIdByRouteId,
OrderedSeasonRoutes: orderedSeasonRoutes,
QuestsWithDifficulty: questsWithDifficulty,
BattleOnlyTargetSceneByQuestId: battleOnlyTargetSceneByQuestId,
UserExpThresholds: BuildExpThresholds(paramMapRows, 1), UserExpThresholds: BuildExpThresholds(paramMapRows, 1),
CharacterExpThresholds: BuildExpThresholds(paramMapRows, 31), CharacterExpThresholds: BuildExpThresholds(paramMapRows, 31),
@@ -545,3 +619,12 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
PartsCatalog: partsCatalog, PartsCatalog: partsCatalog,
}, nil }, 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]
}
+2
View File
@@ -53,9 +53,11 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
} }
user.EventQuest.CurrentEventQuestChapterId = 0
user.EventQuest.CurrentQuestId = 0 user.EventQuest.CurrentQuestId = 0
user.EventQuest.CurrentQuestSceneId = 0 user.EventQuest.CurrentQuestSceneId = 0
user.EventQuest.HeadQuestSceneId = 0 user.EventQuest.HeadQuestSceneId = 0
user.EventQuest.LatestVersion = nowMillis
h.clearQuestMissions(user, questId, nowMillis) h.clearQuestMissions(user, questId, nowMillis)
+127 -40
View File
@@ -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) { func (h *QuestHandler) HandleQuestStart(user *store.UserState, questId int32, isBattleOnly, isMainFlow bool, userDeckNumber int32, nowMillis int64) {
h.handleQuestStartInternal(user, questId, isBattleOnly, userDeckNumber, false, nowMillis) h.handleQuestStartInternal(user, questId, isBattleOnly, isMainFlow, userDeckNumber, false, nowMillis)
} }
func (h *QuestHandler) HandleQuestStartReplay(user *store.UserState, questId int32, isBattleOnly bool, userDeckNumber int32, nowMillis int64) { 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] quest, ok := h.QuestById[questId]
if !ok { if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleQuestStart", questId)) panic(fmt.Sprintf("unknown questId=%d for HandleQuestStart", questId))
} }
h.initQuestState(user, questId) h.initQuestState(user, questId)
if quest.Stamina > 0 { if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 store.ConsumeStamina(user, quest.Stamina, h.MaxStaminaByLevel[user.Status.Level]*1000, nowMillis)
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis)
} }
questState := user.Quests[questId] questState := user.Quests[questId]
if questState.QuestStateType == model.UserQuestStateTypeCleared { questState.IsBattleOnly = isBattleOnly
if isReplayFlow { questState.UserDeckNumber = userDeckNumber
user.MainQuest.SavedCurrentQuestSceneId = user.MainQuest.CurrentQuestSceneId
user.MainQuest.SavedHeadQuestSceneId = user.MainQuest.HeadQuestSceneId isCleared := questState.QuestStateType == model.UserQuestStateTypeCleared
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeReplayFlow) isMenuPick := !isReplayFlow && !isMainFlow && (isCleared || h.QuestHasDifficulty(questId))
user.MainQuest.ReplayFlowCurrentQuestSceneId = 0
user.MainQuest.ReplayFlowHeadQuestSceneId = 0 switch {
user.MainQuest.LatestVersion = nowMillis case isMenuPick:
questState.QuestStateType = model.UserQuestStateTypeActive snapshotMainQuestIfNeeded(user)
questState.LatestStartDatetime = nowMillis sceneId := h.menuPickSceneId(questId, isBattleOnly)
questState.IsBattleOnly = isBattleOnly user.MainQuest.ProgressQuestSceneId = sceneId
questState.UserDeckNumber = userDeckNumber user.MainQuest.ProgressHeadQuestSceneId = sceneId
user.Quests[questId] = questState user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeMainFlow)
log.Printf("[HandleQuestStart] replay flow started for quest %d, saved scene=%d head=%d", user.PortalCageStatus.IsCurrentProgress = false
questId, user.MainQuest.SavedCurrentQuestSceneId, user.MainQuest.SavedHeadQuestSceneId) 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 return
} }
questState.IsBattleOnly = isBattleOnly
questState.UserDeckNumber = userDeckNumber
if isMainQuestPlayable(quest) { if isMainQuestPlayable(quest) {
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow) user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow)
questState.QuestStateType = model.UserQuestStateTypeActive questState.QuestStateType = model.UserQuestStateTypeActive
@@ -90,7 +103,6 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i
questState.ClearCount = 1 questState.ClearCount = 1
questState.DailyClearCount = 1 questState.DailyClearCount = 1
questState.LastClearDatetime = nowMillis questState.LastClearDatetime = nowMillis
if sceneIds := h.SceneIdsByQuestId[questId]; len(sceneIds) > 0 { if sceneIds := h.SceneIdsByQuestId[questId]; len(sceneIds) > 0 {
firstSceneId := sceneIds[0] firstSceneId := sceneIds[0]
prevSceneId := user.MainQuest.CurrentQuestSceneId prevSceneId := user.MainQuest.CurrentQuestSceneId
@@ -102,6 +114,33 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i
user.Quests[questId] = questState 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) { func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, outcome *FinishOutcome, nowMillis int64) {
questState := user.Quests[questId] questState := user.Quests[questId]
if !questState.IsRewardGranted { if !questState.IsRewardGranted {
@@ -122,22 +161,49 @@ func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, o
questState.ClearCount++ questState.ClearCount++
questState.DailyClearCount++ questState.DailyClearCount++
questState.LastClearDatetime = nowMillis questState.LastClearDatetime = nowMillis
questState.IsBattleOnly = false
user.Quests[questId] = questState 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 { func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, isRetired, isAnnihilated bool, nowMillis int64) FinishOutcome {
quest, ok := h.QuestById[questId] quest, ok := h.QuestById[questId]
if !ok { if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleQuestFinish", questId)) panic(fmt.Sprintf("unknown questId=%d for HandleQuestFinish", questId))
} }
h.initQuestState(user, questId)
outcome := h.evaluateFinishOutcome(user, questId) outcome := h.evaluateFinishOutcome(user, questId)
wasReplay := user.MainQuest.CurrentQuestFlowType == int32(model.QuestFlowTypeReplayFlow) wasReplay := user.MainQuest.CurrentQuestFlowType == int32(model.QuestFlowTypeReplayFlow)
wasMenuReplay := user.MainQuest.SavedContext.Active
if !isRetired { if !isRetired {
h.applyQuestVictory(user, questId, &outcome, nowMillis) h.applyQuestVictory(user, questId, &outcome, nowMillis)
if isMainQuestPlayable(quest) && !wasReplay { if isMainQuestPlayable(quest) && !wasReplay && !wasMenuReplay {
lastSceneId := h.getLastMainFlowSceneId(questId) lastSceneId := h.getLastMainFlowSceneId(questId)
h.advanceMainFlowScene(user, questId, lastSceneId) 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) 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.ProgressQuestSceneId = 0
user.MainQuest.ProgressHeadQuestSceneId = 0 user.MainQuest.ProgressHeadQuestSceneId = 0
user.MainQuest.ProgressQuestFlowType = 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 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.ReplayFlowCurrentQuestSceneId = 0
user.MainQuest.ReplayFlowHeadQuestSceneId = 0 user.MainQuest.ReplayFlowHeadQuestSceneId = 0
log.Printf("[HandleQuestFinish] replay flow ended for quest %d, restored scene=%d head=%d", log.Printf("[HandleQuestFinish] replay flow ended for quest %d", questId)
questId, user.MainQuest.CurrentQuestSceneId, user.MainQuest.HeadQuestSceneId)
} }
h.clearQuestMissions(user, questId, nowMillis) 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) { func (h *QuestHandler) HandleQuestRestart(user *store.UserState, questId int32, nowMillis int64) {
questDef, ok := h.QuestById[questId] 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) user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow)
} }
quest := user.Quests[questId] quest := user.Quests[questId]
quest.QuestId = questId quest.QuestId = questId
quest.QuestStateType = model.UserQuestStateTypeActive quest.QuestStateType = model.UserQuestStateTypeActive
quest.IsBattleOnly = false
quest.LatestStartDatetime = nowMillis quest.LatestStartDatetime = nowMillis
user.Quests[questId] = quest user.Quests[questId] = quest
+33 -3
View File
@@ -27,10 +27,11 @@ func (h *QuestHandler) isSceneAhead(newSceneId, currentHeadId int32) bool {
} }
func (h *QuestHandler) advanceMainFlowScene(user *store.UserState, questId, sceneId int32) { func (h *QuestHandler) advanceMainFlowScene(user *store.UserState, questId, sceneId int32) {
user.MainQuest.CurrentQuestSceneId = sceneId if !h.isSceneAhead(sceneId, user.MainQuest.HeadQuestSceneId) {
if h.isSceneAhead(sceneId, user.MainQuest.HeadQuestSceneId) { return
user.MainQuest.HeadQuestSceneId = sceneId
} }
user.MainQuest.CurrentQuestSceneId = sceneId
user.MainQuest.HeadQuestSceneId = sceneId
lastSceneId := h.getChapterLastSceneId(questId) lastSceneId := h.getChapterLastSceneId(questId)
user.MainQuest.IsReachedLastQuestScene = sceneId == lastSceneId user.MainQuest.IsReachedLastQuestScene = sceneId == lastSceneId
@@ -39,10 +40,33 @@ func (h *QuestHandler) advanceMainFlowScene(user *store.UserState, questId, scen
user.MainQuest.CurrentMainQuestRouteId = routeId user.MainQuest.CurrentMainQuestRouteId = routeId
if seasonId, ok := h.SeasonIdByRouteId[routeId]; ok { if seasonId, ok := h.SeasonIdByRouteId[routeId]; ok {
user.MainQuest.MainQuestSeasonId = seasonId 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) { func (h *QuestHandler) HandleMainFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
scene, ok := h.SceneById[questSceneId] scene, ok := h.SceneById[questSceneId]
if !ok { if !ok {
@@ -127,6 +151,12 @@ func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, quest
panic(fmt.Sprintf("unknown questId=%d for HandleMainQuestSceneProgress", questSceneId)) 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 isMainQuestPlayable(quest) {
if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult { if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult {
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
+2 -1
View File
@@ -74,7 +74,7 @@ func (s *QuestServiceServer) StartMainQuest(ctx context.Context, req *pb.StartMa
if req.IsReplayFlow { if req.IsReplayFlow {
engine.HandleQuestStartReplay(user, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis) engine.HandleQuestStartReplay(user, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis)
} else { } 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 user.MainQuest.CurrentMainQuestRouteId = req.MainQuestRouteId
if seasonId, ok := engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok { if seasonId, ok := engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok {
user.MainQuest.MainQuestSeasonId = seasonId user.MainQuest.MainQuestSeasonId = seasonId
questflow.RecordSeasonRoute(user, seasonId, req.MainQuestRouteId, gametime.NowMillis())
} }
now := gametime.NowMillis() now := gametime.NowMillis()
user.PortalCageStatus.IsCurrentProgress = false user.PortalCageStatus.IsCurrentProgress = false
+23 -3
View File
@@ -19,6 +19,8 @@ import (
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -26,15 +28,16 @@ type UserServiceServer struct {
pb.UnimplementedUserServiceServer pb.UnimplementedUserServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
holder *runtime.Holder
authURL string authURL string
noRegister bool 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, "://") { if authURL != "" && !strings.Contains(authURL, "://") {
authURL = "http://" + 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) { 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) userId := CurrentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) { 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 return &pb.GameStartResponse{}, nil
+13 -2
View File
@@ -126,7 +126,7 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
if thresholds, ok := catalog.ExpByEnhanceId[levelingEnhanceId]; ok { if thresholds, ok := catalog.ExpByEnhanceId[levelingEnhanceId]; ok {
weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds) weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds)
if maxFunc, ok := catalog.MaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]; ok { 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 { if weapon.Level > cap {
weapon.Level = cap weapon.Level = cap
if int(cap) >= 0 && int(cap) < len(thresholds) { 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 { if thresholds, ok := catalog.ExpByEnhanceId[levelingEnhanceId]; ok {
weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds) weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds)
if maxFunc, ok := catalog.MaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]; ok { 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 { if weapon.Level > cap {
weapon.Level = cap weapon.Level = cap
if int(cap) >= 0 && int(cap) < len(thresholds) { 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 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
}
+23 -3
View File
@@ -81,6 +81,7 @@ func initMaps(u *store.UserState) {
u.CharacterRebirths = make(map[int32]store.CharacterRebirthState) u.CharacterRebirths = make(map[int32]store.CharacterRebirthState)
u.AutoSaleSettings = make(map[int32]store.AutoSaleSettingState) u.AutoSaleSettings = make(map[int32]store.AutoSaleSettingState)
u.SideStoryQuests = make(map[int32]store.SideStoryQuestProgress) u.SideStoryQuests = make(map[int32]store.SideStoryQuestProgress)
u.MainQuestSeasonRoutes = make(map[store.SeasonRouteKey]store.SeasonRouteEntry)
u.QuestLimitContentStatus = make(map[int32]store.QuestLimitContentStatus) u.QuestLimitContentStatus = make(map[int32]store.QuestLimitContentStatus)
u.BigHuntMaxScores = make(map[int32]store.BigHuntMaxScore) u.BigHuntMaxScores = make(map[int32]store.BigHuntMaxScore)
u.BigHuntStatuses = make(map[int32]store.BigHuntStatus) 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, Scan(&u.LoginBonus.LoginBonusId, &u.LoginBonus.CurrentPageNumber, &u.LoginBonus.CurrentStampNumber,
&u.LoginBonus.LatestRewardReceiveDatetime, &u.LoginBonus.LatestVersion) &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, _ = 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, 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, progress_quest_flow_type, main_quest_season_id, latest_version,
saved_head_quest_scene_id, replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id 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). FROM user_main_quest WHERE user_id=?`, uid).
Scan(&u.MainQuest.CurrentQuestFlowType, &u.MainQuest.CurrentMainQuestRouteId, &u.MainQuest.CurrentQuestSceneId, Scan(&u.MainQuest.CurrentQuestFlowType, &u.MainQuest.CurrentMainQuestRouteId, &u.MainQuest.CurrentQuestSceneId,
&u.MainQuest.HeadQuestSceneId, &b, &u.MainQuest.ProgressQuestSceneId, &u.MainQuest.ProgressHeadQuestSceneId, &u.MainQuest.HeadQuestSceneId, &b, &u.MainQuest.ProgressQuestSceneId, &u.MainQuest.ProgressHeadQuestSceneId,
&u.MainQuest.ProgressQuestFlowType, &u.MainQuest.MainQuestSeasonId, &u.MainQuest.LatestVersion, &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.ReplayFlowCurrentQuestSceneId, &u.MainQuest.ReplayFlowHeadQuestSceneId)
u.MainQuest.IsReachedLastQuestScene = b != 0 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, _ = 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). 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 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) { FROM user_quest_limit_content_status WHERE user_id=?`, uid, func(rows *sql.Rows) {
var id int32 var id int32
+32 -4
View File
@@ -50,11 +50,16 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
u.LoginBonus.LatestRewardReceiveDatetime, u.LoginBonus.LatestVersion); err != nil { u.LoginBonus.LatestRewardReceiveDatetime, u.LoginBonus.LatestVersion); err != nil {
return err 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, uid, u.MainQuest.CurrentQuestFlowType, u.MainQuest.CurrentMainQuestRouteId, u.MainQuest.CurrentQuestSceneId,
u.MainQuest.HeadQuestSceneId, boolToInt(u.MainQuest.IsReachedLastQuestScene), u.MainQuest.ProgressQuestSceneId, u.MainQuest.HeadQuestSceneId, boolToInt(u.MainQuest.IsReachedLastQuestScene), u.MainQuest.ProgressQuestSceneId,
u.MainQuest.ProgressHeadQuestSceneId, u.MainQuest.ProgressQuestFlowType, u.MainQuest.MainQuestSeasonId, 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 { u.MainQuest.ReplayFlowCurrentQuestSceneId, u.MainQuest.ReplayFlowHeadQuestSceneId); err != nil {
return err return err
} }
@@ -208,6 +213,12 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
return err 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 { 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 (?,?,?,?,?)`, 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 { 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 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.CurrentQuestFlowType, after.MainQuest.CurrentMainQuestRouteId, after.MainQuest.CurrentQuestSceneId,
after.MainQuest.HeadQuestSceneId, boolToInt(after.MainQuest.IsReachedLastQuestScene), after.MainQuest.ProgressQuestSceneId, after.MainQuest.HeadQuestSceneId, boolToInt(after.MainQuest.IsReachedLastQuestScene), after.MainQuest.ProgressQuestSceneId,
after.MainQuest.ProgressHeadQuestSceneId, after.MainQuest.ProgressQuestFlowType, after.MainQuest.MainQuestSeasonId, 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 { after.MainQuest.ReplayFlowCurrentQuestSceneId, after.MainQuest.ReplayFlowHeadQuestSceneId, uid); err != nil {
return err return err
} }
@@ -734,6 +750,18 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
func(v store.SideStoryQuestProgress) []any { func(v store.SideStoryQuestProgress) []any {
return []any{0, v.HeadSideStoryQuestSceneId, int32(v.SideStoryQuestStateType), v.LatestVersion} 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") }, "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", diffMapInt32(tx, uid, before.QuestLimitContentStatus, after.QuestLimitContentStatus, "user_quest_limit_content_status", "limit_content_id",
func(v store.QuestLimitContentStatus) []any { func(v store.QuestLimitContentStatus) []any {
return []any{0, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion} return []any{0, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion}
+1
View File
@@ -109,6 +109,7 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error {
"user_big_hunt_max_scores", "user_big_hunt_max_scores",
"user_quest_limit_content_status", "user_quest_limit_content_status",
"user_side_story_quests", "user_side_story_quests",
"user_main_quest_season_routes",
"user_missions", "user_missions",
"user_quest_missions", "user_quest_missions",
"user_quests", "user_quests",
+29 -2
View File
@@ -41,6 +41,7 @@ type UserState struct {
LoginBonus UserLoginBonusState LoginBonus UserLoginBonusState
Tutorials map[int32]TutorialProgressState Tutorials map[int32]TutorialProgressState
MainQuest MainQuestState MainQuest MainQuestState
MainQuestSeasonRoutes map[SeasonRouteKey]SeasonRouteEntry
EventQuest EventQuestState EventQuest EventQuestState
ExtraQuest ExtraQuestState ExtraQuest ExtraQuestState
SideStoryQuests map[int32]SideStoryQuestProgress SideStoryQuests map[int32]SideStoryQuestProgress
@@ -153,6 +154,9 @@ func (u *UserState) EnsureMaps() {
if u.SideStoryQuests == nil { if u.SideStoryQuests == nil {
u.SideStoryQuests = make(map[int32]SideStoryQuestProgress) u.SideStoryQuests = make(map[int32]SideStoryQuestProgress)
} }
if u.MainQuestSeasonRoutes == nil {
u.MainQuestSeasonRoutes = make(map[SeasonRouteKey]SeasonRouteEntry)
}
if u.QuestLimitContentStatus == nil { if u.QuestLimitContentStatus == nil {
u.QuestLimitContentStatus = make(map[int32]QuestLimitContentStatus) u.QuestLimitContentStatus = make(map[int32]QuestLimitContentStatus)
} }
@@ -510,12 +514,24 @@ type MainQuestState struct {
MainQuestSeasonId int32 MainQuestSeasonId int32
LatestVersion int64 LatestVersion int64
SavedCurrentQuestSceneId int32 SavedContext SavedQuestContext
SavedHeadQuestSceneId int32
ReplayFlowCurrentQuestSceneId int32 ReplayFlowCurrentQuestSceneId int32
ReplayFlowHeadQuestSceneId 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 { type EventQuestState struct {
CurrentEventQuestChapterId int32 CurrentEventQuestChapterId int32
CurrentQuestId int32 CurrentQuestId int32
@@ -543,6 +559,17 @@ type SideStoryActiveProgress struct {
LatestVersion int64 LatestVersion int64
} }
type SeasonRouteKey struct {
MainQuestSeasonId int32
MainQuestRouteId int32
}
type SeasonRouteEntry struct {
MainQuestSeasonId int32
MainQuestRouteId int32
LatestVersion int64
}
type QuestLimitContentStatus struct { type QuestLimitContentStatus struct {
LimitContentQuestStatusType int32 LimitContentQuestStatusType int32
EventQuestChapterId int32 EventQuestChapterId int32
+5 -1
View File
@@ -100,9 +100,11 @@ func ChangedTables(before, after *store.UserState) []string {
add("IUserMainQuestFlowStatus") add("IUserMainQuestFlowStatus")
add("IUserMainQuestMainFlowStatus") add("IUserMainQuestMainFlowStatus")
add("IUserMainQuestProgressStatus") add("IUserMainQuestProgressStatus")
add("IUserMainQuestSeasonRoute")
add("IUserMainQuestReplayFlowStatus") add("IUserMainQuestReplayFlowStatus")
} }
if !mapsEqualStruct(before.MainQuestSeasonRoutes, after.MainQuestSeasonRoutes) {
add("IUserMainQuestSeasonRoute")
}
if before.EventQuest != after.EventQuest { if before.EventQuest != after.EventQuest {
add("IUserEventQuestProgressStatus") add("IUserEventQuestProgressStatus")
} }
@@ -434,6 +436,8 @@ func keyFieldsForTable(table string) []string {
return []string{"userId", "dokanId"} return []string{"userId", "dokanId"}
case "IUserSideStoryQuest": case "IUserSideStoryQuest":
return []string{"userId", "sideStoryQuestId"} return []string{"userId", "sideStoryQuestId"}
case "IUserMainQuestSeasonRoute":
return []string{"userId", "mainQuestSeasonId", "mainQuestRouteId"}
case "IUserQuestLimitContentStatus": case "IUserQuestLimitContentStatus":
return []string{"userId", "questId"} return []string{"userId", "questId"}
case "IUserBigHuntMaxScore": case "IUserBigHuntMaxScore":
+30 -5
View File
@@ -98,12 +98,37 @@ func init() {
return s return s
}) })
register("IUserMainQuestSeasonRoute", func(user store.UserState) string { register("IUserMainQuestSeasonRoute", func(user store.UserState) string {
s, _ := utils.EncodeJSONMaps(map[string]any{ if len(user.MainQuestSeasonRoutes) == 0 {
"userId": user.UserId, // Fallback to current (season, route) for legacy saves with no history.
"mainQuestSeasonId": user.MainQuest.MainQuestSeasonId, s, _ := utils.EncodeJSONMaps(map[string]any{
"mainQuestRouteId": user.MainQuest.CurrentMainQuestRouteId, "userId": user.UserId,
"latestVersion": user.MainQuest.LatestVersion, "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 return s
}) })
register("IUserEventQuestProgressStatus", func(user store.UserState) string { register("IUserEventQuestProgressStatus", func(user store.UserState) string {
@@ -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;
@@ -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;