Fix map replay flow and quest mission rewards
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled

This commit is contained in:
Ilya Groshev
2026-05-11 20:21:55 +03:00
parent 9a2cc92a6f
commit 6c9e3c45f0
14 changed files with 241 additions and 188 deletions
-46
View File
@@ -12,34 +12,6 @@ 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
@@ -62,7 +34,6 @@ 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 QuestsWithDifficulty map[int32]bool // any questId referenced in m_quest_relation_main_flow
BattleOnlyTargetSceneByQuestId map[int32]int32 BattleOnlyTargetSceneByQuestId map[int32]int32
@@ -148,22 +119,6 @@ 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)
@@ -600,7 +555,6 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
TutorialUnlockConditions: tutorialUnlockConds, TutorialUnlockConditions: tutorialUnlockConds,
ChapterLastSceneByQuestId: chapterLastSceneByQuestId, ChapterLastSceneByQuestId: chapterLastSceneByQuestId,
SeasonIdByRouteId: seasonIdByRouteId, SeasonIdByRouteId: seasonIdByRouteId,
OrderedSeasonRoutes: orderedSeasonRoutes,
QuestsWithDifficulty: questsWithDifficulty, QuestsWithDifficulty: questsWithDifficulty,
BattleOnlyTargetSceneByQuestId: battleOnlyTargetSceneByQuestId, BattleOnlyTargetSceneByQuestId: battleOnlyTargetSceneByQuestId,
+9
View File
@@ -12,6 +12,15 @@ const (
QuestFlowTypeAnotherRouteReplayFlow QuestFlowType = 4 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 { func (t QuestFlowType) String() string {
switch t { switch t {
case QuestFlowTypeUnknown: case QuestFlowTypeUnknown:
+1 -1
View File
@@ -35,7 +35,7 @@ func (h *QuestHandler) HandleBigHuntQuestFinish(user *store.UserState, questId i
outcome := h.evaluateFinishOutcome(user, questId) outcome := h.evaluateFinishOutcome(user, questId)
if !isRetired { if !isRetired {
h.applyQuestVictory(user, questId, &outcome, nowMillis) h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
} }
if isRetired && !isAnnihilated && quest.Stamina > 1 { if isRetired && !isAnnihilated && quest.Stamina > 1 {
+2 -7
View File
@@ -44,7 +44,7 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC
outcome := h.evaluateFinishOutcome(user, questId) outcome := h.evaluateFinishOutcome(user, questId)
if !isRetired { if !isRetired {
h.applyQuestVictory(user, questId, &outcome, nowMillis) h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
} }
if isRetired && !isAnnihilated && quest.Stamina > 1 { 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) { func (h *QuestHandler) HandleEventQuestSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
scene, ok := h.SceneById[questSceneId] if _, ok := h.SceneById[questSceneId]; !ok {
if !ok {
log.Printf("[HandleEventQuestSceneProgress] unknown sceneId=%d, skipping", questSceneId) log.Printf("[HandleEventQuestSceneProgress] unknown sceneId=%d, skipping", questSceneId)
return return
} }
@@ -84,8 +83,4 @@ func (h *QuestHandler) HandleEventQuestSceneProgress(user *store.UserState, ques
} }
h.applySceneGrants(user, questSceneId, nowMillis) h.applySceneGrants(user, questSceneId, nowMillis)
if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult {
h.clearQuestMissions(user, scene.QuestId, nowMillis)
}
} }
+2 -7
View File
@@ -42,7 +42,7 @@ func (h *QuestHandler) HandleExtraQuestFinish(user *store.UserState, questId int
outcome := h.evaluateFinishOutcome(user, questId) outcome := h.evaluateFinishOutcome(user, questId)
if !isRetired { if !isRetired {
h.applyQuestVictory(user, questId, &outcome, nowMillis) h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
} }
if isRetired && !isAnnihilated && quest.Stamina > 1 { 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) { func (h *QuestHandler) HandleExtraQuestSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
scene, ok := h.SceneById[questSceneId] if _, ok := h.SceneById[questSceneId]; !ok {
if !ok {
log.Printf("[HandleExtraQuestSceneProgress] unknown sceneId=%d, skipping", questSceneId) log.Printf("[HandleExtraQuestSceneProgress] unknown sceneId=%d, skipping", questSceneId)
return return
} }
@@ -79,8 +78,4 @@ func (h *QuestHandler) HandleExtraQuestSceneProgress(user *store.UserState, ques
} }
h.applySceneGrants(user, questSceneId, nowMillis) h.applySceneGrants(user, questSceneId, nowMillis)
if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult {
h.clearQuestMissions(user, scene.QuestId, nowMillis)
}
} }
+49 -24
View File
@@ -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", log.Printf("[HandleQuestStart] QuestMenuPick quest=%d isBattleOnly=%v scene=%d cleared=%v",
questId, isBattleOnly, sceneId, isCleared) questId, isBattleOnly, sceneId, isCleared)
case isCleared && isReplayFlow: case isReplayFlow:
snapshotMainQuestIfNeeded(user) h.applyReplayStart(user, questId, isBattleOnly, nowMillis)
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeReplayFlow) return
user.MainQuest.ReplayFlowCurrentQuestSceneId = 0
user.MainQuest.ReplayFlowHeadQuestSceneId = 0
user.MainQuest.LatestVersion = nowMillis
log.Printf("[HandleQuestStart] MapPlay quest=%d isBattleOnly=%v", questId, isBattleOnly)
} }
if isCleared { 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 { func (h *QuestHandler) menuPickSceneId(questId int32, isBattleOnly bool) int32 {
if isBattleOnly { if isBattleOnly {
if v, ok := h.BattleOnlyTargetSceneIdFor(questId); ok { if v, ok := h.BattleOnlyTargetSceneIdFor(questId); ok {
@@ -141,14 +158,24 @@ func (h *QuestHandler) menuPickSceneId(questId int32, isBattleOnly bool) int32 {
return 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, wasReplay bool) {
questState := user.Quests[questId] questState := user.Quests[questId]
if !questState.IsRewardGranted { if !questState.IsRewardGranted {
h.applyQuestRewards(user, questId, nowMillis) h.applyExpAndGoldRewards(user, questId, nowMillis)
outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds, if !wasReplay {
h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeHalfResult, nowMillis)...) h.applyFirstClearItemRewards(user, questId, nowMillis)
outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds, outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds,
h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeFullResult, nowMillis)...) 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 questState.IsRewardGranted = true
} }
for _, drop := range outcome.DropRewards { for _, drop := range outcome.DropRewards {
@@ -197,13 +224,13 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i
h.initQuestState(user, questId) h.initQuestState(user, questId)
outcome := h.evaluateFinishOutcome(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 wasMenuReplay := user.MainQuest.SavedContext.Active
if !isRetired { 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) lastSceneId := h.getLastMainFlowSceneId(questId)
h.advanceMainFlowScene(user, questId, lastSceneId) 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.ProgressQuestSceneId = 0
user.MainQuest.ProgressHeadQuestSceneId = 0 user.MainQuest.ProgressHeadQuestSceneId = 0
user.MainQuest.ProgressQuestFlowType = 0 if !wasReplay {
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow) // 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 { if wasMenuReplay {
ctx := user.MainQuest.SavedContext ctx := user.MainQuest.SavedContext
@@ -248,12 +279,6 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i
ctx.CurrentQuestSceneId, ctx.HeadQuestSceneId, ctx.PortalCageInProgress) 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) h.clearQuestMissions(user, questId, nowMillis)
return outcome return outcome
+54 -36
View File
@@ -51,7 +51,9 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3
panic(fmt.Sprintf("unknown questId=%d for evaluateFinishOutcome", questId)) 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) rewardGroupId := h.firstClearRewardGroupId(user, questDef)
for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] { for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] {
outcome.FirstClearRewards = append(outcome.FirstClearRewards, RewardGrant{ 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] { for _, reward := range h.ReplayFlowRewardsByGroupId[questDef.QuestReplayFlowRewardGroupId] {
outcome.ReplayFlowFirstClearRewards = append(outcome.ReplayFlowFirstClearRewards, RewardGrant{ outcome.ReplayFlowFirstClearRewards = append(outcome.ReplayFlowFirstClearRewards, RewardGrant{
PossessionType: model.PossessionType(reward.PossessionType), PossessionType: model.PossessionType(reward.PossessionType),
@@ -72,48 +74,53 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3
} }
} }
pendingClearCount := 0 // Mission rewards / BigWin are first-clear concepts. Reference
regularMissionCount := 0 // IUserQuestMissionTable has no rows for replay-variant ids (30000+):
for _, questMissionId := range h.MissionIdsByQuestId[questId] { // the popup is empty on replay in the original game.
missionDef, ok := h.MissionById[questMissionId] if !isReplay {
if !ok || model.QuestMissionConditionType(missionDef.QuestMissionConditionType) == model.QuestMissionConditionTypeComplete { pendingClearCount := 0
continue regularMissionCount := 0
}
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 {
for _, questMissionId := range h.MissionIdsByQuestId[questId] { for _, questMissionId := range h.MissionIdsByQuestId[questId] {
missionDef, ok := h.MissionById[questMissionId] missionDef, ok := h.MissionById[questMissionId]
if !ok || model.QuestMissionConditionType(missionDef.QuestMissionConditionType) != model.QuestMissionConditionTypeComplete { if !ok || model.QuestMissionConditionType(missionDef.QuestMissionConditionType) == model.QuestMissionConditionTypeComplete {
continue continue
} }
regularMissionCount++
key := store.QuestMissionKey{QuestId: questId, QuestMissionId: questMissionId} key := store.QuestMissionKey{QuestId: questId, QuestMissionId: questMissionId}
if !user.QuestMissions[key].IsClear { mission := user.QuestMissions[key]
outcome.MissionClearCompleteRewards = appendMissionRewards(
outcome.MissionClearCompleteRewards, if !mission.IsClear {
pendingClearCount++
outcome.MissionClearRewards = appendMissionRewards(
outcome.MissionClearRewards,
h.MissionRewardsByMissionId[missionDef.QuestMissionRewardId], 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) outcome.DropRewards = h.computeDropRewards(questDef)
@@ -240,7 +247,7 @@ func (h *QuestHandler) resolveDeckUnits(user *store.UserState, questId int32) (c
return costumeUuids, characterIds 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] questDef, ok := h.QuestById[questId]
if !ok { if !ok {
return return
@@ -252,13 +259,24 @@ func (h *QuestHandler) applyQuestRewards(user *store.UserState, questId int32, n
user.ConsumableItems[h.Config.ConsumableItemIdForGold] += questDef.Gold user.ConsumableItems[h.Config.ConsumableItemIdForGold] += questDef.Gold
log.Printf("[applyQuestRewards] questId=%d gold: +%d -> total=%d", questId, questDef.Gold, user.ConsumableItems[h.Config.ConsumableItemIdForGold]) 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) rewardGroupId := h.firstClearRewardGroupId(user, questDef)
for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] { for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] {
h.applyRewardPossession(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis) 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) { func (h *QuestHandler) applyRewardPossession(user *store.UserState, possType model.PossessionType, possId, count int32, nowMillis int64) {
h.Granter.GrantFull(user, possType, possId, count, nowMillis) h.Granter.GrantFull(user, possType, possId, count, nowMillis)
} }
+70 -16
View File
@@ -1,5 +1,11 @@
package questflow package questflow
// MainQuest scene-field families mirror three client entity tables:
//
// MainFlow* — EntityIUserMainQuestMainFlowStatus (#11443)
// Progress* — EntityIUserMainQuestProgressStatus (#11444)
// ReplayFlow* — EntityIUserMainQuestReplayFlowStatus (#11445)
import ( import (
"fmt" "fmt"
"log" "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, // Backs IUserMainQuestSeasonRoute: the client needs the history to load
// bumping LatestVersion on first insert. The history backs IUserMainQuestSeasonRoute, // scene metadata when cage menu-replay jumps to older chapters.
// 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) { func RecordSeasonRoute(user *store.UserState, seasonId, routeId int32, nowMillis int64) {
if seasonId <= 0 || routeId <= 0 { if seasonId <= 0 || routeId <= 0 {
return return
@@ -133,11 +137,48 @@ func (h *QuestHandler) getChapterLastSceneId(questId int32) int32 {
func (h *QuestHandler) HandleReplayFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) { func (h *QuestHandler) HandleReplayFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
user.MainQuest.ReplayFlowCurrentQuestSceneId = questSceneId 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 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) { 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 prevSceneId := user.MainQuest.ProgressQuestSceneId; prevSceneId != 0 {
if prevScene, ok := h.SceneById[prevSceneId]; ok && prevScene.QuestId != quest.QuestId { 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) { isReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType)
if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult {
nowMillis := gametime.NowMillis()
h.clearQuestMissions(user, quest.QuestId, nowMillis)
}
if isMainQuestPlayable(quest) {
user.MainQuest.ProgressQuestSceneId = questSceneId user.MainQuest.ProgressQuestSceneId = questSceneId
if h.isSceneAhead(questSceneId, user.MainQuest.ProgressHeadQuestSceneId) { if h.isSceneAhead(questSceneId, user.MainQuest.ProgressHeadQuestSceneId) {
user.MainQuest.ProgressHeadQuestSceneId = questSceneId user.MainQuest.ProgressHeadQuestSceneId = questSceneId
} }
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeSubFlow) if isReplay {
user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeSubFlow) user.MainQuest.ProgressQuestFlowType = user.MainQuest.CurrentQuestFlowType
} else {
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeSubFlow)
user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeSubFlow)
}
} else { } else {
user.MainQuest.CurrentQuestSceneId = questSceneId user.MainQuest.CurrentQuestSceneId = questSceneId
if h.isSceneAhead(questSceneId, user.MainQuest.HeadQuestSceneId) { if h.isSceneAhead(questSceneId, user.MainQuest.HeadQuestSceneId) {
@@ -177,4 +223,12 @@ func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, quest
lastSceneId := h.getChapterLastSceneId(quest.QuestId) lastSceneId := h.getChapterLastSceneId(quest.QuestId)
user.MainQuest.IsReachedLastQuestScene = questSceneId == lastSceneId 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()
}
} }
+7
View File
@@ -6,6 +6,7 @@ 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/store" "lunar-tear/server/internal/store"
) )
@@ -27,6 +28,12 @@ func (s *PortalCageServiceServer) UpdatePortalCageSceneProgress(ctx context.Cont
now := gametime.NowMillis() now := gametime.NowMillis()
user.PortalCageStatus.IsCurrentProgress = true user.PortalCageStatus.IsCurrentProgress = true
user.PortalCageStatus.LatestVersion = now 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 return &pb.UpdatePortalCageSceneProgressResponse{}, nil
} }
+1 -19
View File
@@ -19,7 +19,6 @@ 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/runtime"
"lunar-tear/server/internal/store" "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) userId := CurrentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
now := gametime.NowMillis() user.GameStartDatetime = 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
-6
View File
@@ -537,7 +537,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.DokanConfirmed[id] = true u.DokanConfirmed[id] = true
}) })
// Gifts
queryRows(db, `SELECT user_gift_uuid, is_received, possession_type, possession_id, count, grant_datetime, 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 description_gift_text_id, equipment_data, expiration_datetime, received_datetime
FROM user_gifts WHERE user_id=?`, uid, func(rows *sql.Rows) { 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, queryRows(db, `SELECT consumable_item_id, count FROM user_gacha_converted_medals WHERE user_id=? ORDER BY ordinal`, uid,
func(rows *sql.Rows) { func(rows *sql.Rows) {
var v store.ConsumableItemState 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) 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 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) { FROM user_gacha_banners WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.GachaBannerState 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, 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, panel_release_bit4, latest_version FROM user_character_boards WHERE user_id=?`, uid,
func(rows *sql.Rows) { func(rows *sql.Rows) {
@@ -642,7 +638,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.ShopReplaceableLineup[v.SlotNumber] = v u.ShopReplaceableLineup[v.SlotNumber] = v
}) })
// Gimmick tables
queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, 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, is_gimmick_cleared, start_datetime, latest_version FROM user_gimmick_progress WHERE user_id=?`, uid,
func(rows *sql.Rows) { func(rows *sql.Rows) {
@@ -685,7 +680,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.Gimmick.Unlocks[v.Key] = v u.Gimmick.Unlocks[v.Key] = v
}) })
// Big hunt maps
queryRows(db, `SELECT big_hunt_boss_id, max_score, max_score_update_datetime, latest_version 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) { FROM user_big_hunt_max_scores WHERE user_id=?`, uid, func(rows *sql.Rows) {
var id int32 var id int32
+2 -26
View File
@@ -15,8 +15,7 @@ func boolToInt(b bool) int {
return 0 return 0
} }
// writeUserState inserts all child table rows for a newly created user. // Precondition: the users row must already exist.
// The users row must already exist.
func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error { func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
exec := func(query string, args ...any) error { exec := func(query string, args ...any) error {
_, err := tx.Exec(query, args...) _, err := tx.Exec(query, args...)
@@ -123,7 +122,6 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
return err return err
} }
// Map tables
for _, v := range u.Characters { for _, v := range u.Characters {
if err := exec(`INSERT INTO user_characters (user_id, character_id, level, exp, latest_version) VALUES (?,?,?,?,?)`, 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 { 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 return nil
} }
// diffAndSave compares before/after UserState and writes only changed rows. // 1:1 tables update on field-change; maps INSERT OR REPLACE + DELETE; slice tables DELETE-all then INSERT-all.
// 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.
func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
exec := func(query string, args ...any) error { exec := func(query string, args ...any) error {
_, err := tx.Exec(query, args...) _, err := tx.Exec(query, args...)
return err return err
} }
// users table
if before.PlayerId != after.PlayerId || before.OsType != after.OsType || before.PlatformType != after.PlatformType || if before.PlayerId != after.PlayerId || before.OsType != after.OsType || before.PlatformType != after.PlatformType ||
before.UserRestrictionType != after.UserRestrictionType || before.RegisterDatetime != after.RegisterDatetime || before.UserRestrictionType != after.UserRestrictionType || before.RegisterDatetime != after.RegisterDatetime ||
before.GameStartDatetime != after.GameStartDatetime || before.LatestVersion != after.LatestVersion || 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 || 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 { before.Gacha.DailyMaxCount != after.Gacha.DailyMaxCount || before.Gacha.LastRewardDrawDate != after.Gacha.LastRewardDrawDate {
var obtainItemId, obtainCount sql.NullInt64 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", 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} }, func(v store.CharacterState) []any { return []any{v.CharacterId, v.Level, v.Exp, v.LatestVersion} },
"character_id, level, exp, latest_version") "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} 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") }, "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 { for k, v := range after.Decks {
if old, ok := before.Decks[k]; !ok || old != v { 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 (?,?,?,?,?,?,?,?,?)`), 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) { replaceSliceTable(tx, uid, "user_deck_sub_weapons", after.DeckSubWeapons, func(key string, uuids []string) {
for i, uuid := range uuids { 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) 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} 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_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 { for k, v := range after.QuestMissions {
if old, ok := before.QuestMissions[k]; !ok || old != v { 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 (?,?,?,?,?,?,?)`, 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} 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_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) exec(`DELETE FROM user_weapon_skills WHERE user_id=?`, uid)
for _, skills := range after.WeaponSkills { for _, skills := range after.WeaponSkills {
for _, v := range skills { 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} return []any{v.UserCostumeUuid, v.Level, v.AcquisitionDatetime, v.LatestVersion}
}, "user_costume_uuid, level, acquisition_datetime, latest_version") }, "user_costume_uuid, level, acquisition_datetime, latest_version")
// Costume awaken status ups (composite key)
for k, v := range after.CostumeAwakenStatusUps { for k, v := range after.CostumeAwakenStatusUps {
if old, ok := before.CostumeAwakenStatusUps[k]; !ok || old != v { 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 (?,?,?,?,?,?,?,?,?,?)`, 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 { for k, v := range after.DeckTypeNotes {
if old, ok := before.DeckTypeNotes[k]; !ok || old != v { 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 (?,?,?,?)`, 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") diffTimestampMap(tx, uid, before.DrawnOmikuji, after.DrawnOmikuji, "user_drawn_omikuji", "omikuji_id")
diffBoolMap(tx, uid, before.DokanConfirmed, after.DokanConfirmed, "user_dokan_confirmed", "dokan_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) exec(`DELETE FROM user_gifts WHERE user_id=?`, uid)
for _, g := range after.Gifts.NotReceived { for _, g := range after.Gifts.NotReceived {
var expDt sql.NullInt64 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) 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) exec(`DELETE FROM user_gacha_converted_medals WHERE user_id=?`, uid)
for i, v := range after.Gacha.ConvertedGachaMedal.ConvertedMedalPossession { 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) 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 { 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 { 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 (?,?,?,?,?,?,?)`, 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) 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) exec(`DELETE FROM user_gacha_banner_box_drew_counts WHERE user_id=? AND gacha_id=?`, uid, id)
for itemId, count := range v.BoxDrewCounts { 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) 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} 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_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 { for k, v := range after.CharacterBoardAbilities {
if old, ok := before.CharacterBoardAbilities[k]; !ok || old != v { 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 (?,?,?,?,?)`, 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 { for k, v := range after.CharacterBoardStatusUps {
if old, ok := before.CharacterBoardStatusUps[k]; !ok || old != v { 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 (?,?,?,?,?,?,?,?,?,?)`, 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") "slot_number, shop_item_id, latest_version")
// Gimmick tables (composite keys)
for k, v := range after.Gimmick.Progress { for k, v := range after.Gimmick.Progress {
if old, ok := before.Gimmick.Progress[k]; !ok || old != v { 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 (?,?,?,?,?,?,?)`, 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", diffMapInt32(tx, uid, before.BigHuntMaxScores, after.BigHuntMaxScores, "user_big_hunt_max_scores", "big_hunt_boss_id",
func(v store.BigHuntMaxScore) []any { func(v store.BigHuntMaxScore) []any {
return []any{0, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion} 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 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) { 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 { for k, v := range after {
if old, ok := before[k]; !ok || old != v { if old, ok := before[k]; !ok || old != v {
+3
View File
@@ -153,6 +153,9 @@ func init() {
return s return s
}) })
register("IUserMainQuestReplayFlowStatus", func(user store.UserState) string { register("IUserMainQuestReplayFlowStatus", func(user store.UserState) string {
if user.MainQuest.ReplayFlowCurrentQuestSceneId == 0 && user.MainQuest.ReplayFlowHeadQuestSceneId == 0 {
return "[]"
}
s, _ := utils.EncodeJSONMaps(map[string]any{ s, _ := utils.EncodeJSONMaps(map[string]any{
"userId": user.UserId, "userId": user.UserId,
"currentHeadQuestSceneId": user.MainQuest.ReplayFlowHeadQuestSceneId, "currentHeadQuestSceneId": user.MainQuest.ReplayFlowHeadQuestSceneId,
@@ -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;