diff --git a/server/cmd/lunar-tear/grpc.go b/server/cmd/lunar-tear/grpc.go index 818bd2a..98ed037 100644 --- a/server/cmd/lunar-tear/grpc.go +++ b/server/cmd/lunar-tear/grpc.go @@ -124,4 +124,5 @@ func registerServices( pb.RegisterSideStoryQuestServiceServer(srv, service.NewSideStoryQuestServiceServer(userStore, userStore, holder)) pb.RegisterBigHuntServiceServer(srv, service.NewBigHuntServiceServer(userStore, userStore, holder)) pb.RegisterRewardServiceServer(srv, service.NewRewardServiceServer(userStore, userStore, holder)) + pb.RegisterLabyrinthServiceServer(srv, service.NewLabyrinthServiceServer(userStore, userStore, holder)) } diff --git a/server/internal/masterdata/labyrinth.go b/server/internal/masterdata/labyrinth.go new file mode 100644 index 0000000..527974c --- /dev/null +++ b/server/internal/masterdata/labyrinth.go @@ -0,0 +1,225 @@ +package masterdata + +import ( + "log" + "sort" + + "lunar-tear/server/internal/utils" +) + +type LabyrinthChapter struct { + EventQuestChapterId int32 + LatestSeasonNumber int32 + StageOrders []int32 +} + +type LabyrinthStageTier struct { + QuestMissionClearCount int32 + Rewards []RewardItem +} + +type LabyrinthSeasonMilestone struct { + HeadQuestId int32 + HeadStageOrder int32 + Rewards []RewardItem +} + +type labyrinthStageKey struct { + ChapterId int32 + StageOrder int32 +} + +type LabyrinthCatalog struct { + ChaptersByOrder []LabyrinthChapter + ClearRewardsByStage map[labyrinthStageKey][]RewardItem + AccumTiersByStage map[labyrinthStageKey][]LabyrinthStageTier + SeasonMilestonesByChapter map[int32][]LabyrinthSeasonMilestone +} + +func (c *LabyrinthCatalog) StageClearReward(chapterId, stageOrder int32) []RewardItem { + return c.ClearRewardsByStage[labyrinthStageKey{chapterId, stageOrder}] +} + +func (c *LabyrinthCatalog) CollectAccumulationRewards(chapterId, stageOrder, oldCount, targetCount int32) ([]RewardItem, int32) { + var items []RewardItem + highest := int32(0) + for _, t := range c.AccumTiersByStage[labyrinthStageKey{chapterId, stageOrder}] { + if t.QuestMissionClearCount > oldCount && t.QuestMissionClearCount <= targetCount { + items = append(items, t.Rewards...) + if t.QuestMissionClearCount > highest { + highest = t.QuestMissionClearCount + } + } + } + return items, highest +} + +func (c *LabyrinthCatalog) SeasonMilestones(chapterId int32) []LabyrinthSeasonMilestone { + return c.SeasonMilestonesByChapter[chapterId] +} + +func LoadLabyrinthCatalog() *LabyrinthCatalog { + seasonRows, err := utils.ReadTable[EntityMEventQuestLabyrinthSeason]("m_event_quest_labyrinth_season") + if err != nil { + log.Printf("[labyrinth] m_event_quest_labyrinth_season unavailable, labyrinth disabled: %v", err) + return &LabyrinthCatalog{} + } + stageRows, err := utils.ReadTable[EntityMEventQuestLabyrinthStage]("m_event_quest_labyrinth_stage") + if err != nil { + log.Printf("[labyrinth] m_event_quest_labyrinth_stage unavailable, labyrinth disabled: %v", err) + return &LabyrinthCatalog{} + } + + // chapterId -> highest SeasonNumber + latestSeason := make(map[int32]int32) + for _, r := range seasonRows { + if r.SeasonNumber > latestSeason[r.EventQuestChapterId] { + latestSeason[r.EventQuestChapterId] = r.SeasonNumber + } + } + // chapterId -> stage orders + stagesByChapter := make(map[int32][]int32) + for _, r := range stageRows { + stagesByChapter[r.EventQuestChapterId] = append(stagesByChapter[r.EventQuestChapterId], r.StageOrder) + } + + chapters := make([]LabyrinthChapter, 0, len(latestSeason)) + for chapterId, season := range latestSeason { + stages := stagesByChapter[chapterId] + sort.Slice(stages, func(i, j int) bool { return stages[i] < stages[j] }) + chapters = append(chapters, LabyrinthChapter{ + EventQuestChapterId: chapterId, + LatestSeasonNumber: season, + StageOrders: stages, + }) + } + sort.Slice(chapters, func(i, j int) bool { + return chapters[i].EventQuestChapterId < chapters[j].EventQuestChapterId + }) + + clearRewards, accumTiers, seasonMilestones := loadLabyrinthRewards(seasonRows, stageRows) + + log.Printf("labyrinth catalog loaded: %d chapters, %d stages with clear rewards, %d with accumulation rewards, %d chapters with season rewards", + len(chapters), len(clearRewards), len(accumTiers), len(seasonMilestones)) + return &LabyrinthCatalog{ + ChaptersByOrder: chapters, + ClearRewardsByStage: clearRewards, + AccumTiersByStage: accumTiers, + SeasonMilestonesByChapter: seasonMilestones, + } +} + +func loadLabyrinthRewards(seasonRows []EntityMEventQuestLabyrinthSeason, stageRows []EntityMEventQuestLabyrinthStage) ( + clearRewards map[labyrinthStageKey][]RewardItem, + accumTiers map[labyrinthStageKey][]LabyrinthStageTier, + seasonMilestones map[int32][]LabyrinthSeasonMilestone, +) { + rewardGroupRows, err := utils.ReadTable[EntityMEventQuestLabyrinthRewardGroup]("m_event_quest_labyrinth_reward_group") + if err != nil { + log.Printf("[labyrinth] m_event_quest_labyrinth_reward_group unavailable, rewards disabled: %v", err) + return nil, nil, nil + } + + // reward group id -> reward items + itemsByRewardGroup := make(map[int32][]RewardItem) + for _, r := range rewardGroupRows { + itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId] = append(itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId], RewardItem{ + PossessionType: r.PossessionType, + PossessionId: r.PossessionId, + Count: r.Count, + }) + } + + // per-stage one-time clear reward + clearRewards = make(map[labyrinthStageKey][]RewardItem) + for _, r := range stageRows { + if r.StageClearRewardGroupId == 0 { + continue + } + if items := itemsByRewardGroup[r.StageClearRewardGroupId]; len(items) > 0 { + clearRewards[labyrinthStageKey{r.EventQuestChapterId, r.StageOrder}] = items + } + } + + if accumGroupRows, err := utils.ReadTable[EntityMEventQuestLabyrinthStageAccumulationRewardGroup]("m_event_quest_labyrinth_stage_accumulation_reward_group"); err != nil { + log.Printf("[labyrinth] m_event_quest_labyrinth_stage_accumulation_reward_group unavailable, accumulation rewards disabled: %v", err) + } else { + // accumulation group id -> tiers (threshold + resolved reward items) + tiersByGroup := make(map[int32][]LabyrinthStageTier) + for _, r := range accumGroupRows { + tiersByGroup[r.EventQuestLabyrinthStageAccumulationRewardGroupId] = append(tiersByGroup[r.EventQuestLabyrinthStageAccumulationRewardGroupId], LabyrinthStageTier{ + QuestMissionClearCount: r.QuestMissionClearCount, + Rewards: itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId], + }) + } + accumTiers = make(map[labyrinthStageKey][]LabyrinthStageTier) + for _, r := range stageRows { + if r.StageAccumulationRewardGroupId == 0 { + continue + } + tiers := tiersByGroup[r.StageAccumulationRewardGroupId] + sort.Slice(tiers, func(i, j int) bool { + return tiers[i].QuestMissionClearCount < tiers[j].QuestMissionClearCount + }) + accumTiers[labyrinthStageKey{r.EventQuestChapterId, r.StageOrder}] = tiers + } + } + + // per-chapter season-reward milestones + if seasonRewardRows, err := utils.ReadTable[EntityMEventQuestLabyrinthSeasonRewardGroup]("m_event_quest_labyrinth_season_reward_group"); err != nil { + log.Printf("[labyrinth] m_event_quest_labyrinth_season_reward_group unavailable, season rewards disabled: %v", err) + } else { + seasonMilestones = buildLabyrinthSeasonMilestones(seasonRows, seasonRewardRows, itemsByRewardGroup) + } + + return clearRewards, accumTiers, seasonMilestones +} + +func buildLabyrinthSeasonMilestones( + seasonRows []EntityMEventQuestLabyrinthSeason, + seasonRewardRows []EntityMEventQuestLabyrinthSeasonRewardGroup, + itemsByRewardGroup map[int32][]RewardItem, +) map[int32][]LabyrinthSeasonMilestone { + // chapter -> SeasonRewardGroupId (all seasons of a chapter share one) + groupByChapter := make(map[int32]int32) + for _, r := range seasonRows { + groupByChapter[r.EventQuestChapterId] = r.SeasonRewardGroupId + } + // SeasonRewardGroupId -> its rows, in table order + rowsByGroup := make(map[int32][]EntityMEventQuestLabyrinthSeasonRewardGroup) + for _, r := range seasonRewardRows { + rowsByGroup[r.EventQuestLabyrinthSeasonRewardGroupId] = append(rowsByGroup[r.EventQuestLabyrinthSeasonRewardGroupId], r) + } + + milestones := make(map[int32][]LabyrinthSeasonMilestone) + for chapterId, seasonGroupId := range groupByChapter { + rows := rowsByGroup[seasonGroupId] + if len(rows) == 0 { + continue + } + // rank distinct reward-group ids ascending -> 1-based head stage order + stageByRewardGroup := make(map[int32]int32) + var distinct []int32 + for _, r := range rows { + if _, seen := stageByRewardGroup[r.EventQuestLabyrinthRewardGroupId]; !seen { + stageByRewardGroup[r.EventQuestLabyrinthRewardGroupId] = 0 + distinct = append(distinct, r.EventQuestLabyrinthRewardGroupId) + } + } + sort.Slice(distinct, func(i, j int) bool { return distinct[i] < distinct[j] }) + for i, gid := range distinct { + stageByRewardGroup[gid] = int32(i + 1) + } + + list := make([]LabyrinthSeasonMilestone, 0, len(rows)) + for _, r := range rows { + list = append(list, LabyrinthSeasonMilestone{ + HeadQuestId: r.HeadQuestId, + HeadStageOrder: stageByRewardGroup[r.EventQuestLabyrinthRewardGroupId], + Rewards: itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId], + }) + } + milestones[chapterId] = list + } + return milestones +} diff --git a/server/internal/model/quest.go b/server/internal/model/quest.go index 02c1f48..af1ecc3 100644 --- a/server/internal/model/quest.go +++ b/server/internal/model/quest.go @@ -12,10 +12,6 @@ const ( QuestFlowTypeAnotherRouteReplayFlow QuestFlowType = 4 ) -// IsReplayQuestFlowType reports whether the flow type indicates an active -// replay session — either same-route REPLAY_FLOW or cross-route -// ANOTHER_ROUTE_REPLAY_FLOW. Mirrors the client's Story.IsReplayQuestFlowType -// predicate (dump.cs:768202). func IsReplayQuestFlowType(t int32) bool { return t == int32(QuestFlowTypeReplayFlow) || t == int32(QuestFlowTypeAnotherRouteReplayFlow) @@ -170,7 +166,6 @@ const ( type SideStorySceneIdType int32 -// Values mirror SideStoryTypes.SceneIdTypes in the client (dump.cs). const ( SideStorySceneInvalid SideStorySceneIdType = 0 SideStorySceneIntroduction SideStorySceneIdType = 1 diff --git a/server/internal/runtime/build.go b/server/internal/runtime/build.go index 27d2139..a425820 100644 --- a/server/internal/runtime/build.go +++ b/server/internal/runtime/build.go @@ -141,6 +141,8 @@ func buildCatalogs() (*Catalogs, error) { towerCatalog := masterdata.LoadTowerCatalog() + labyrinthCatalog := masterdata.LoadLabyrinthCatalog() + return &Catalogs{ GameConfig: gameConfig, Parts: partsCatalog, @@ -167,6 +169,7 @@ func buildCatalogs() (*Catalogs, error) { SideStory: sideStoryCatalog, BigHunt: bigHuntCatalog, Tower: towerCatalog, + Labyrinth: labyrinthCatalog, QuestHandler: questHandler, GachaHandler: gachaHandler, }, nil diff --git a/server/internal/runtime/holder.go b/server/internal/runtime/holder.go index cc06e74..eaf4834 100644 --- a/server/internal/runtime/holder.go +++ b/server/internal/runtime/holder.go @@ -51,6 +51,7 @@ type Catalogs struct { SideStory *masterdata.SideStoryCatalog BigHunt *masterdata.BigHuntCatalog Tower *masterdata.TowerCatalog + Labyrinth *masterdata.LabyrinthCatalog // Catalog-derived handlers must rebuild on every reload because they // embed/cache pointers to specific catalog instances. diff --git a/server/internal/service/labyrinth.go b/server/internal/service/labyrinth.go new file mode 100644 index 0000000..9a0b28a --- /dev/null +++ b/server/internal/service/labyrinth.go @@ -0,0 +1,138 @@ +package service + +import ( + "context" + "log" + + pb "lunar-tear/server/gen/proto" + "lunar-tear/server/internal/gametime" + "lunar-tear/server/internal/model" + "lunar-tear/server/internal/runtime" + "lunar-tear/server/internal/store" +) + +type LabyrinthServiceServer struct { + pb.UnimplementedLabyrinthServiceServer + users store.UserRepository + sessions store.SessionRepository + holder *runtime.Holder +} + +func NewLabyrinthServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *LabyrinthServiceServer { + if holder == nil { + panic("runtime holder is required") + } + return &LabyrinthServiceServer{users: users, sessions: sessions, holder: holder} +} + +func (s *LabyrinthServiceServer) ReceiveStageAccumulationReward(ctx context.Context, req *pb.ReceiveStageAccumulationRewardRequest) (*pb.ReceiveStageAccumulationRewardResponse, error) { + log.Printf("[LabyrinthService] ReceiveStageAccumulationReward: chapter=%d stage=%d questMissionClearCount=%d", + req.EventQuestChapterId, req.StageOrder, req.QuestMissionClearCount) + + cat := s.holder.Get() + laby := cat.Labyrinth + granter := cat.QuestHandler.Granter + + userId := CurrentUserId(ctx, s.users, s.sessions) + nowMillis := gametime.NowMillis() + + key := store.LabyrinthStageKey{ + EventQuestChapterId: req.EventQuestChapterId, + StageOrder: req.StageOrder, + } + + s.users.UpdateUser(userId, func(user *store.UserState) { + rec := user.LabyrinthStages[key] + old := rec.AccumulationRewardReceivedQuestMissionCount + + items, highest := laby.CollectAccumulationRewards(req.EventQuestChapterId, req.StageOrder, old, req.QuestMissionClearCount) + if highest <= old { + log.Printf("[LabyrinthService] ReceiveStageAccumulationReward: nothing to grant for chapter=%d stage=%d (claimed=%d, target=%d)", + req.EventQuestChapterId, req.StageOrder, old, req.QuestMissionClearCount) + return + } + + for _, it := range items { + granter.GrantFull(user, model.PossessionType(it.PossessionType), it.PossessionId, it.Count, nowMillis) + } + + rec.EventQuestChapterId = req.EventQuestChapterId + rec.StageOrder = req.StageOrder + rec.AccumulationRewardReceivedQuestMissionCount = highest + rec.LatestVersion = nowMillis + user.LabyrinthStages[key] = rec + + log.Printf("[LabyrinthService] ReceiveStageAccumulationReward: chapter=%d stage=%d granted %d item(s), claimed %d -> %d", + req.EventQuestChapterId, req.StageOrder, len(items), old, highest) + }) + + return &pb.ReceiveStageAccumulationRewardResponse{}, nil +} + +func (s *LabyrinthServiceServer) ReceiveStageClearReward(ctx context.Context, req *pb.ReceiveStageClearRewardRequest) (*pb.ReceiveStageClearRewardResponse, error) { + log.Printf("[LabyrinthService] ReceiveStageClearReward: chapter=%d stage=%d", + req.EventQuestChapterId, req.StageOrder) + + cat := s.holder.Get() + laby := cat.Labyrinth + granter := cat.QuestHandler.Granter + + userId := CurrentUserId(ctx, s.users, s.sessions) + nowMillis := gametime.NowMillis() + + key := store.LabyrinthStageKey{ + EventQuestChapterId: req.EventQuestChapterId, + StageOrder: req.StageOrder, + } + + s.users.UpdateUser(userId, func(user *store.UserState) { + rec := user.LabyrinthStages[key] + if rec.IsReceivedStageClearReward { + log.Printf("[LabyrinthService] ReceiveStageClearReward: already claimed chapter=%d stage=%d", + req.EventQuestChapterId, req.StageOrder) + return + } + + items := laby.StageClearReward(req.EventQuestChapterId, req.StageOrder) + for _, it := range items { + granter.GrantFull(user, model.PossessionType(it.PossessionType), it.PossessionId, it.Count, nowMillis) + } + + rec.EventQuestChapterId = req.EventQuestChapterId + rec.StageOrder = req.StageOrder + rec.IsReceivedStageClearReward = true + rec.LatestVersion = nowMillis + user.LabyrinthStages[key] = rec + + log.Printf("[LabyrinthService] ReceiveStageClearReward: chapter=%d stage=%d granted %d item(s)", + req.EventQuestChapterId, req.StageOrder, len(items)) + }) + + return &pb.ReceiveStageClearRewardResponse{}, nil +} + +func (s *LabyrinthServiceServer) UpdateSeasonData(ctx context.Context, req *pb.UpdateSeasonDataRequest) (*pb.UpdateSeasonDataResponse, error) { + laby := s.holder.Get().Labyrinth + + var seasonResult []*pb.LabyrinthSeasonResult + for _, m := range laby.SeasonMilestones(req.EventQuestChapterId) { + rewards := make([]*pb.LabyrinthReward, 0, len(m.Rewards)) + for _, it := range m.Rewards { + rewards = append(rewards, &pb.LabyrinthReward{ + PossessionType: it.PossessionType, + PossessionId: it.PossessionId, + Count: it.Count, + }) + } + seasonResult = append(seasonResult, &pb.LabyrinthSeasonResult{ + EventQuestChapterId: req.EventQuestChapterId, + HeadQuestId: m.HeadQuestId, + SeasonReward: rewards, + HeadStageOrder: m.HeadStageOrder, + }) + } + + log.Printf("[LabyrinthService] UpdateSeasonData: chapter=%d -> %d milestone(s)", + req.EventQuestChapterId, len(seasonResult)) + return &pb.UpdateSeasonDataResponse{SeasonResult: seasonResult}, nil +} diff --git a/server/internal/store/clone.go b/server/internal/store/clone.go index 3c6fda2..7b9ba5f 100644 --- a/server/internal/store/clone.go +++ b/server/internal/store/clone.go @@ -27,6 +27,8 @@ func CloneUserState(u UserState) UserState { } out.CageOrnamentRewards = maps.Clone(u.CageOrnamentRewards) out.TowerAccumulationRewards = maps.Clone(u.TowerAccumulationRewards) + out.LabyrinthSeasons = maps.Clone(u.LabyrinthSeasons) + out.LabyrinthStages = maps.Clone(u.LabyrinthStages) out.ConsumableItems = maps.Clone(u.ConsumableItems) out.Materials = maps.Clone(u.Materials) out.Parts = maps.Clone(u.Parts) diff --git a/server/internal/store/seed.go b/server/internal/store/seed.go index d903feb..8d741b3 100644 --- a/server/internal/store/seed.go +++ b/server/internal/store/seed.go @@ -125,6 +125,8 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl }, CageOrnamentRewards: make(map[int32]CageOrnamentRewardState), TowerAccumulationRewards: make(map[int32]TowerAccumulationRewardState), + LabyrinthSeasons: make(map[int32]LabyrinthSeasonState), + LabyrinthStages: make(map[LabyrinthStageKey]LabyrinthStageState), ConsumableItems: make(map[int32]int32), Materials: make(map[int32]int32), Thoughts: make(map[string]ThoughtState), diff --git a/server/internal/store/sqlite/load.go b/server/internal/store/sqlite/load.go index 877049d..ee93181 100644 --- a/server/internal/store/sqlite/load.go +++ b/server/internal/store/sqlite/load.go @@ -78,6 +78,8 @@ func initMaps(u *store.UserState) { u.ExploreScores = make(map[int32]store.ExploreScoreState) u.CageOrnamentRewards = make(map[int32]store.CageOrnamentRewardState) u.TowerAccumulationRewards = make(map[int32]store.TowerAccumulationRewardState) + u.LabyrinthSeasons = make(map[int32]store.LabyrinthSeasonState) + u.LabyrinthStages = make(map[store.LabyrinthStageKey]store.LabyrinthStageState) u.CharacterBoards = make(map[int32]store.CharacterBoardState) u.CharacterBoardAbilities = make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState) u.CharacterBoardStatusUps = make(map[store.CharacterBoardStatusUpKey]store.CharacterBoardStatusUpState) @@ -659,6 +661,22 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) { u.TowerAccumulationRewards[v.EventQuestChapterId] = v }) + queryRows(db, `SELECT event_quest_chapter_id, last_join_season_number, last_season_reward_received_season_number, latest_version + FROM user_event_quest_labyrinth_seasons WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.LabyrinthSeasonState + rows.Scan(&v.EventQuestChapterId, &v.LastJoinSeasonNumber, &v.LastSeasonRewardReceivedSeasonNumber, &v.LatestVersion) + u.LabyrinthSeasons[v.EventQuestChapterId] = v + }) + + queryRows(db, `SELECT event_quest_chapter_id, stage_order, is_received_stage_clear_reward, accumulation_reward_received_quest_mission_count, latest_version + FROM user_event_quest_labyrinth_stages WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.LabyrinthStageState + var rcvd int + rows.Scan(&v.EventQuestChapterId, &v.StageOrder, &rcvd, &v.AccumulationRewardReceivedQuestMissionCount, &v.LatestVersion) + v.IsReceivedStageClearReward = rcvd != 0 + u.LabyrinthStages[store.LabyrinthStageKey{EventQuestChapterId: v.EventQuestChapterId, StageOrder: v.StageOrder}] = v + }) + queryRows(db, `SELECT shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version FROM user_shop_items WHERE user_id=?`, uid, func(rows *sql.Rows) { var v store.UserShopItemState diff --git a/server/internal/store/sqlite/save.go b/server/internal/store/sqlite/save.go index 02a9317..7689f48 100644 --- a/server/internal/store/sqlite/save.go +++ b/server/internal/store/sqlite/save.go @@ -460,6 +460,18 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error { return err } } + for _, v := range u.LabyrinthSeasons { + if err := exec(`INSERT INTO user_event_quest_labyrinth_seasons (user_id, event_quest_chapter_id, last_join_season_number, last_season_reward_received_season_number, latest_version) VALUES (?,?,?,?,?)`, + uid, v.EventQuestChapterId, v.LastJoinSeasonNumber, v.LastSeasonRewardReceivedSeasonNumber, v.LatestVersion); err != nil { + return err + } + } + for k, v := range u.LabyrinthStages { + if err := exec(`INSERT INTO user_event_quest_labyrinth_stages (user_id, event_quest_chapter_id, stage_order, is_received_stage_clear_reward, accumulation_reward_received_quest_mission_count, latest_version) VALUES (?,?,?,?,?,?)`, + uid, k.EventQuestChapterId, k.StageOrder, boolToInt(v.IsReceivedStageClearReward), v.AccumulationRewardReceivedQuestMissionCount, v.LatestVersion); err != nil { + return err + } + } for _, v := range u.ShopItems { if err := exec(`INSERT INTO user_shop_items (user_id, shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version) VALUES (?,?,?,?,?)`, uid, v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion); err != nil { @@ -1005,6 +1017,22 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { return []any{v.EventQuestChapterId, v.LatestRewardReceiveQuestMissionClearCount, v.LatestVersion} }, "event_quest_chapter_id, latest_reward_receive_quest_mission_clear_count, latest_version") + diffMapInt32(tx, uid, before.LabyrinthSeasons, after.LabyrinthSeasons, "user_event_quest_labyrinth_seasons", "event_quest_chapter_id", + func(v store.LabyrinthSeasonState) []any { + return []any{v.EventQuestChapterId, v.LastJoinSeasonNumber, v.LastSeasonRewardReceivedSeasonNumber, v.LatestVersion} + }, + "event_quest_chapter_id, last_join_season_number, last_season_reward_received_season_number, latest_version") + for k, v := range after.LabyrinthStages { + if old, ok := before.LabyrinthStages[k]; !ok || old != v { + exec(`INSERT OR REPLACE INTO user_event_quest_labyrinth_stages (user_id, event_quest_chapter_id, stage_order, is_received_stage_clear_reward, accumulation_reward_received_quest_mission_count, latest_version) VALUES (?,?,?,?,?,?)`, + uid, k.EventQuestChapterId, k.StageOrder, boolToInt(v.IsReceivedStageClearReward), v.AccumulationRewardReceivedQuestMissionCount, v.LatestVersion) + } + } + for k := range before.LabyrinthStages { + if _, ok := after.LabyrinthStages[k]; !ok { + exec(`DELETE FROM user_event_quest_labyrinth_stages WHERE user_id=? AND event_quest_chapter_id=? AND stage_order=?`, uid, k.EventQuestChapterId, k.StageOrder) + } + } diffMapInt32(tx, uid, before.ShopItems, after.ShopItems, "user_shop_items", "shop_item_id", func(v store.UserShopItemState) []any { return []any{v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion} diff --git a/server/internal/store/sqlite/user.go b/server/internal/store/sqlite/user.go index 1ce4192..52e9cca 100644 --- a/server/internal/store/sqlite/user.go +++ b/server/internal/store/sqlite/user.go @@ -79,6 +79,8 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error { // Child tables in reverse-dependency order (matches schema's goose Down). childTables := []string{ + "user_event_quest_labyrinth_stages", + "user_event_quest_labyrinth_seasons", "user_event_quest_tower_accumulation_rewards", "user_cage_ornament_rewards", "user_shop_replaceable_lineup", diff --git a/server/internal/store/types.go b/server/internal/store/types.go index 22260d0..6ae3752 100644 --- a/server/internal/store/types.go +++ b/server/internal/store/types.go @@ -78,6 +78,8 @@ type UserState struct { Gimmick GimmickState CageOrnamentRewards map[int32]CageOrnamentRewardState TowerAccumulationRewards map[int32]TowerAccumulationRewardState + LabyrinthSeasons map[int32]LabyrinthSeasonState + LabyrinthStages map[LabyrinthStageKey]LabyrinthStageState ConsumableItems map[int32]int32 Materials map[int32]int32 Parts map[string]PartsState @@ -196,6 +198,12 @@ func (u *UserState) EnsureMaps() { if u.TowerAccumulationRewards == nil { u.TowerAccumulationRewards = make(map[int32]TowerAccumulationRewardState) } + if u.LabyrinthSeasons == nil { + u.LabyrinthSeasons = make(map[int32]LabyrinthSeasonState) + } + if u.LabyrinthStages == nil { + u.LabyrinthStages = make(map[LabyrinthStageKey]LabyrinthStageState) + } if u.ConsumableItems == nil { u.ConsumableItems = make(map[int32]int32) } @@ -878,6 +886,27 @@ type TowerAccumulationRewardState struct { LatestVersion int64 } +type LabyrinthSeasonState struct { + EventQuestChapterId int32 + LastJoinSeasonNumber int32 + LastSeasonRewardReceivedSeasonNumber int32 + LatestVersion int64 +} + +// LabyrinthStageKey is the composite key for UserState.LabyrinthStages. +type LabyrinthStageKey struct { + EventQuestChapterId int32 + StageOrder int32 +} + +type LabyrinthStageState struct { + EventQuestChapterId int32 + StageOrder int32 + IsReceivedStageClearReward bool + AccumulationRewardReceivedQuestMissionCount int32 + LatestVersion int64 +} + type PartsState struct { UserPartsUuid string PartsId int32 diff --git a/server/internal/userdata/changed_tables.go b/server/internal/userdata/changed_tables.go index 856b629..2df6109 100644 --- a/server/internal/userdata/changed_tables.go +++ b/server/internal/userdata/changed_tables.go @@ -266,6 +266,9 @@ func ChangedTables(before, after *store.UserState) []string { if !mapsEqualStruct(before.TowerAccumulationRewards, after.TowerAccumulationRewards) { add("IUserEventQuestTowerAccumulationReward") } + if !mapsEqualStruct(before.LabyrinthStages, after.LabyrinthStages) { + add("IUserEventQuestLabyrinthStage") + } if !mapsEqualStruct(before.BigHuntMaxScores, after.BigHuntMaxScores) { add("IUserBigHuntMaxScore") @@ -431,6 +434,8 @@ func keyFieldsForTable(table string) []string { return []string{"userId", "cageOrnamentId"} case "IUserEventQuestTowerAccumulationReward": return []string{"userId", "eventQuestChapterId"} + case "IUserEventQuestLabyrinthStage": + return []string{"userId", "eventQuestChapterId", "stageOrder"} case "IUserAutoSaleSettingDetail": return []string{"userId", "possessionAutoSaleItemType"} case "IUserCharacterRebirth": diff --git a/server/internal/userdata/proj_labyrinth.go b/server/internal/userdata/proj_labyrinth.go new file mode 100644 index 0000000..5e90a5e --- /dev/null +++ b/server/internal/userdata/proj_labyrinth.go @@ -0,0 +1,72 @@ +package userdata + +import ( + "sync" + + "lunar-tear/server/internal/masterdata" + "lunar-tear/server/internal/store" + "lunar-tear/server/internal/utils" +) + +var labyrinthCatalog = sync.OnceValue(masterdata.LoadLabyrinthCatalog) + +func init() { + register("IUserEventQuestLabyrinthSeason", func(user store.UserState) string { + chapters := labyrinthCatalog().ChaptersByOrder + records := make([]map[string]any, 0, len(chapters)) + for _, ch := range chapters { + if st, ok := user.LabyrinthSeasons[ch.EventQuestChapterId]; ok { + records = append(records, map[string]any{ + "userId": user.UserId, + "eventQuestChapterId": st.EventQuestChapterId, + "lastJoinSeasonNumber": st.LastJoinSeasonNumber, + "lastSeasonRewardReceivedSeasonNumber": st.LastSeasonRewardReceivedSeasonNumber, + "latestVersion": st.LatestVersion, + }) + continue + } + records = append(records, map[string]any{ + "userId": user.UserId, + "eventQuestChapterId": ch.EventQuestChapterId, + "lastJoinSeasonNumber": ch.LatestSeasonNumber, + "lastSeasonRewardReceivedSeasonNumber": 0, + "latestVersion": user.GameStartDatetime, + }) + } + s, _ := utils.EncodeJSONMaps(records...) + return s + }) + + register("IUserEventQuestLabyrinthStage", func(user store.UserState) string { + records := make([]map[string]any, 0) + for _, ch := range labyrinthCatalog().ChaptersByOrder { + for _, stageOrder := range ch.StageOrders { + key := store.LabyrinthStageKey{ + EventQuestChapterId: ch.EventQuestChapterId, + StageOrder: stageOrder, + } + if st, ok := user.LabyrinthStages[key]; ok { + records = append(records, map[string]any{ + "userId": user.UserId, + "eventQuestChapterId": st.EventQuestChapterId, + "stageOrder": st.StageOrder, + "isReceivedStageClearReward": st.IsReceivedStageClearReward, + "accumulationRewardReceivedQuestMissionCount": st.AccumulationRewardReceivedQuestMissionCount, + "latestVersion": st.LatestVersion, + }) + continue + } + records = append(records, map[string]any{ + "userId": user.UserId, + "eventQuestChapterId": ch.EventQuestChapterId, + "stageOrder": stageOrder, + "isReceivedStageClearReward": false, + "accumulationRewardReceivedQuestMissionCount": 0, + "latestVersion": user.GameStartDatetime, + }) + } + } + s, _ := utils.EncodeJSONMaps(records...) + return s + }) +} diff --git a/server/internal/userdata/proj_quest.go b/server/internal/userdata/proj_quest.go index 5c3a7bf..c91b50f 100644 --- a/server/internal/userdata/proj_quest.go +++ b/server/internal/userdata/proj_quest.go @@ -243,8 +243,6 @@ func init() { }) registerStatic( "IUserEventQuestDailyGroupCompleteReward", - "IUserEventQuestLabyrinthSeason", - "IUserEventQuestLabyrinthStage", "IUserQuestReplayFlowRewardGroup", "IUserQuestAutoOrbit", "IUserQuestSceneChoice", diff --git a/server/migrations/20260517103753_add_user_labyrinth_state.sql b/server/migrations/20260517103753_add_user_labyrinth_state.sql new file mode 100644 index 0000000..1e0a9b7 --- /dev/null +++ b/server/migrations/20260517103753_add_user_labyrinth_state.sql @@ -0,0 +1,23 @@ +-- +goose Up +CREATE TABLE user_event_quest_labyrinth_seasons ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + event_quest_chapter_id INTEGER NOT NULL, + last_join_season_number INTEGER NOT NULL DEFAULT 0, + last_season_reward_received_season_number INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, event_quest_chapter_id) +); + +CREATE TABLE user_event_quest_labyrinth_stages ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + event_quest_chapter_id INTEGER NOT NULL, + stage_order INTEGER NOT NULL, + is_received_stage_clear_reward INTEGER NOT NULL DEFAULT 0, + accumulation_reward_received_quest_mission_count INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, event_quest_chapter_id, stage_order) +); + +-- +goose Down +DROP TABLE IF EXISTS user_event_quest_labyrinth_stages; +DROP TABLE IF EXISTS user_event_quest_labyrinth_seasons;