diff --git a/server/internal/masterdata/tower.go b/server/internal/masterdata/tower.go new file mode 100644 index 0000000..8a42516 --- /dev/null +++ b/server/internal/masterdata/tower.go @@ -0,0 +1,84 @@ +package masterdata + +import ( + "log" + "sort" + + "lunar-tear/server/internal/utils" +) + +type TowerTier struct { + QuestMissionClearCount int32 + Rewards []RewardItem +} + +type TowerCatalog struct { + TiersByChapter map[int32][]TowerTier +} + +func (c *TowerCatalog) CollectRewards(chapterId, oldCount, targetCount int32) ([]RewardItem, int32) { + var items []RewardItem + highest := int32(0) + for _, t := range c.TiersByChapter[chapterId] { + if t.QuestMissionClearCount > oldCount && t.QuestMissionClearCount <= targetCount { + items = append(items, t.Rewards...) + if t.QuestMissionClearCount > highest { + highest = t.QuestMissionClearCount + } + } + } + return items, highest +} + +func LoadTowerCatalog() *TowerCatalog { + // chapterId -> accumulation reward group id + accumRewardRows, err := utils.ReadTable[EntityMEventQuestTowerAccumulationReward]("m_event_quest_tower_accumulation_reward") + if err != nil { + log.Fatalf("load event quest tower accumulation reward table: %v", err) + } + groupByChapter := make(map[int32]int32, len(accumRewardRows)) + for _, r := range accumRewardRows { + groupByChapter[r.EventQuestChapterId] = r.EventQuestTowerAccumulationRewardGroupId + } + + // reward group id -> reward items + rewardGroupRows, err := utils.ReadTable[EntityMEventQuestTowerRewardGroup]("m_event_quest_tower_reward_group") + if err != nil { + log.Fatalf("load event quest tower reward group table: %v", err) + } + itemsByRewardGroup := make(map[int32][]RewardItem) + for _, r := range rewardGroupRows { + itemsByRewardGroup[r.EventQuestTowerRewardGroupId] = append(itemsByRewardGroup[r.EventQuestTowerRewardGroupId], RewardItem{ + PossessionType: r.PossessionType, + PossessionId: r.PossessionId, + Count: r.Count, + }) + } + + // accumulation group id -> tiers (threshold + resolved reward items) + accumGroupRows, err := utils.ReadTable[EntityMEventQuestTowerAccumulationRewardGroup]("m_event_quest_tower_accumulation_reward_group") + if err != nil { + log.Fatalf("load event quest tower accumulation reward group table: %v", err) + } + tiersByGroup := make(map[int32][]TowerTier) + for _, r := range accumGroupRows { + tiersByGroup[r.EventQuestTowerAccumulationRewardGroupId] = append(tiersByGroup[r.EventQuestTowerAccumulationRewardGroupId], TowerTier{ + QuestMissionClearCount: r.QuestMissionClearCount, + Rewards: itemsByRewardGroup[r.EventQuestTowerRewardGroupId], + }) + } + + // resolve per-chapter, sorted ascending by threshold + tiersByChapter := make(map[int32][]TowerTier, len(groupByChapter)) + for chapterId, groupId := range groupByChapter { + tiers := tiersByGroup[groupId] + sort.Slice(tiers, func(i, j int) bool { + return tiers[i].QuestMissionClearCount < tiers[j].QuestMissionClearCount + }) + tiersByChapter[chapterId] = tiers + } + + log.Printf("tower catalog loaded: %d chapters", len(tiersByChapter)) + + return &TowerCatalog{TiersByChapter: tiersByChapter} +} diff --git a/server/internal/runtime/build.go b/server/internal/runtime/build.go index 4032637..27d2139 100644 --- a/server/internal/runtime/build.go +++ b/server/internal/runtime/build.go @@ -139,6 +139,8 @@ func buildCatalogs() (*Catalogs, error) { bigHuntCatalog := masterdata.LoadBigHuntCatalog() + towerCatalog := masterdata.LoadTowerCatalog() + return &Catalogs{ GameConfig: gameConfig, Parts: partsCatalog, @@ -164,6 +166,7 @@ func buildCatalogs() (*Catalogs, error) { Companion: companionCatalog, SideStory: sideStoryCatalog, BigHunt: bigHuntCatalog, + Tower: towerCatalog, QuestHandler: questHandler, GachaHandler: gachaHandler, }, nil diff --git a/server/internal/runtime/holder.go b/server/internal/runtime/holder.go index ef22bf2..cc06e74 100644 --- a/server/internal/runtime/holder.go +++ b/server/internal/runtime/holder.go @@ -50,6 +50,7 @@ type Catalogs struct { Companion *masterdata.CompanionCatalog SideStory *masterdata.SideStoryCatalog BigHunt *masterdata.BigHuntCatalog + Tower *masterdata.TowerCatalog // Catalog-derived handlers must rebuild on every reload because they // embed/cache pointers to specific catalog instances. diff --git a/server/internal/service/quest_tower.go b/server/internal/service/quest_tower.go new file mode 100644 index 0000000..2969b94 --- /dev/null +++ b/server/internal/service/quest_tower.go @@ -0,0 +1,49 @@ +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/store" +) + +func (s *QuestServiceServer) ReceiveTowerAccumulationReward(ctx context.Context, req *pb.ReceiveTowerAccumulationRewardRequest) (*pb.ReceiveTowerAccumulationRewardResponse, error) { + log.Printf("[QuestService] ReceiveTowerAccumulationReward: eventQuestChapterId=%d targetMissionClearCount=%d", + req.EventQuestChapterId, req.TargetMissionClearCount) + + cat := s.holder.Get() + tower := cat.Tower + granter := cat.QuestHandler.Granter + + userId := CurrentUserId(ctx, s.users, s.sessions) + nowMillis := gametime.NowMillis() + + s.users.UpdateUser(userId, func(user *store.UserState) { + rec := user.TowerAccumulationRewards[req.EventQuestChapterId] + old := rec.LatestRewardReceiveQuestMissionClearCount + + items, highest := tower.CollectRewards(req.EventQuestChapterId, old, req.TargetMissionClearCount) + if highest <= old { + log.Printf("[QuestService] ReceiveTowerAccumulationReward: nothing to grant for chapter=%d (claimed=%d, target=%d)", + req.EventQuestChapterId, old, req.TargetMissionClearCount) + return + } + + for _, it := range items { + granter.GrantFull(user, model.PossessionType(it.PossessionType), it.PossessionId, it.Count, nowMillis) + } + + rec.EventQuestChapterId = req.EventQuestChapterId + rec.LatestRewardReceiveQuestMissionClearCount = highest + rec.LatestVersion = nowMillis + user.TowerAccumulationRewards[req.EventQuestChapterId] = rec + + log.Printf("[QuestService] ReceiveTowerAccumulationReward: chapter=%d granted %d item(s), claimed %d -> %d", + req.EventQuestChapterId, len(items), old, highest) + }) + + return &pb.ReceiveTowerAccumulationRewardResponse{}, nil +} diff --git a/server/internal/store/clone.go b/server/internal/store/clone.go index 4611e20..3c6fda2 100644 --- a/server/internal/store/clone.go +++ b/server/internal/store/clone.go @@ -26,6 +26,7 @@ func CloneUserState(u UserState) UserState { Unlocks: maps.Clone(u.Gimmick.Unlocks), } out.CageOrnamentRewards = maps.Clone(u.CageOrnamentRewards) + out.TowerAccumulationRewards = maps.Clone(u.TowerAccumulationRewards) 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 49bb25e..d903feb 100644 --- a/server/internal/store/seed.go +++ b/server/internal/store/seed.go @@ -123,30 +123,31 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl Sequences: make(map[GimmickSequenceKey]GimmickSequenceState), Unlocks: make(map[GimmickKey]GimmickUnlockState), }, - CageOrnamentRewards: make(map[int32]CageOrnamentRewardState), - ConsumableItems: make(map[int32]int32), - Materials: make(map[int32]int32), - Thoughts: make(map[string]ThoughtState), - Parts: make(map[string]PartsState), - PartsGroupNotes: make(map[int32]PartsGroupNoteState), - PartsPresets: make(map[int32]PartsPresetState), - PartsPresetTags: make(map[int32]PartsPresetTagState), - PartsStatusSubs: make(map[PartsStatusSubKey]PartsStatusSubState), - ImportantItems: make(map[int32]int32), - CostumeActiveSkills: make(map[string]CostumeActiveSkillState), - WeaponSkills: make(map[string][]WeaponSkillState), - WeaponAbilities: make(map[string][]WeaponAbilityState), - DeckTypeNotes: make(map[model.DeckType]DeckTypeNoteState), - WeaponNotes: make(map[int32]WeaponNoteState), - NaviCutInPlayed: make(map[int32]bool), - ViewedMovies: make(map[int32]int64), - ContentsStories: make(map[int32]int64), - DrawnOmikuji: make(map[int32]int64), - PremiumItems: make(map[int32]int64), - DokanConfirmed: make(map[int32]bool), - ShopItems: make(map[int32]UserShopItemState), - ShopReplaceableLineup: make(map[int32]UserShopReplaceableLineupState), - ExploreScores: make(map[int32]ExploreScoreState), + CageOrnamentRewards: make(map[int32]CageOrnamentRewardState), + TowerAccumulationRewards: make(map[int32]TowerAccumulationRewardState), + ConsumableItems: make(map[int32]int32), + Materials: make(map[int32]int32), + Thoughts: make(map[string]ThoughtState), + Parts: make(map[string]PartsState), + PartsGroupNotes: make(map[int32]PartsGroupNoteState), + PartsPresets: make(map[int32]PartsPresetState), + PartsPresetTags: make(map[int32]PartsPresetTagState), + PartsStatusSubs: make(map[PartsStatusSubKey]PartsStatusSubState), + ImportantItems: make(map[int32]int32), + CostumeActiveSkills: make(map[string]CostumeActiveSkillState), + WeaponSkills: make(map[string][]WeaponSkillState), + WeaponAbilities: make(map[string][]WeaponAbilityState), + DeckTypeNotes: make(map[model.DeckType]DeckTypeNoteState), + WeaponNotes: make(map[int32]WeaponNoteState), + NaviCutInPlayed: make(map[int32]bool), + ViewedMovies: make(map[int32]int64), + ContentsStories: make(map[int32]int64), + DrawnOmikuji: make(map[int32]int64), + PremiumItems: make(map[int32]int64), + DokanConfirmed: make(map[int32]bool), + ShopItems: make(map[int32]UserShopItemState), + ShopReplaceableLineup: make(map[int32]UserShopReplaceableLineupState), + ExploreScores: make(map[int32]ExploreScoreState), CharacterBoards: make(map[int32]CharacterBoardState), CharacterBoardAbilities: make(map[CharacterBoardAbilityKey]CharacterBoardAbilityState), diff --git a/server/internal/store/sqlite/load.go b/server/internal/store/sqlite/load.go index 9ddf83a..877049d 100644 --- a/server/internal/store/sqlite/load.go +++ b/server/internal/store/sqlite/load.go @@ -77,6 +77,7 @@ func initMaps(u *store.UserState) { u.ShopReplaceableLineup = make(map[int32]store.UserShopReplaceableLineupState) u.ExploreScores = make(map[int32]store.ExploreScoreState) u.CageOrnamentRewards = make(map[int32]store.CageOrnamentRewardState) + u.TowerAccumulationRewards = make(map[int32]store.TowerAccumulationRewardState) u.CharacterBoards = make(map[int32]store.CharacterBoardState) u.CharacterBoardAbilities = make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState) u.CharacterBoardStatusUps = make(map[store.CharacterBoardStatusUpKey]store.CharacterBoardStatusUpState) @@ -651,6 +652,13 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) { u.CageOrnamentRewards[v.CageOrnamentId] = v }) + queryRows(db, `SELECT event_quest_chapter_id, latest_reward_receive_quest_mission_clear_count, latest_version + FROM user_event_quest_tower_accumulation_rewards WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.TowerAccumulationRewardState + rows.Scan(&v.EventQuestChapterId, &v.LatestRewardReceiveQuestMissionClearCount, &v.LatestVersion) + u.TowerAccumulationRewards[v.EventQuestChapterId] = 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 b837ebe..02a9317 100644 --- a/server/internal/store/sqlite/save.go +++ b/server/internal/store/sqlite/save.go @@ -454,6 +454,12 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error { return err } } + for _, v := range u.TowerAccumulationRewards { + if err := exec(`INSERT INTO user_event_quest_tower_accumulation_rewards (user_id, event_quest_chapter_id, latest_reward_receive_quest_mission_clear_count, latest_version) VALUES (?,?,?,?)`, + uid, v.EventQuestChapterId, v.LatestRewardReceiveQuestMissionClearCount, 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 { @@ -994,6 +1000,11 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { return []any{v.CageOrnamentId, v.AcquisitionDatetime, v.LatestVersion} }, "cage_ornament_id, acquisition_datetime, latest_version") + diffMapInt32(tx, uid, before.TowerAccumulationRewards, after.TowerAccumulationRewards, "user_event_quest_tower_accumulation_rewards", "event_quest_chapter_id", + func(v store.TowerAccumulationRewardState) []any { + 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.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 4ed74ca..1ce4192 100644 --- a/server/internal/store/sqlite/user.go +++ b/server/internal/store/sqlite/user.go @@ -79,6 +79,7 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error { // Child tables in reverse-dependency order (matches schema's goose Down). childTables := []string{ + "user_event_quest_tower_accumulation_rewards", "user_cage_ornament_rewards", "user_shop_replaceable_lineup", "user_shop_items", diff --git a/server/internal/store/types.go b/server/internal/store/types.go index c2404fa..22260d0 100644 --- a/server/internal/store/types.go +++ b/server/internal/store/types.go @@ -63,47 +63,48 @@ type UserState struct { Gacha GachaState Notifications NotificationState - Characters map[int32]CharacterState - Costumes map[string]CostumeState - Weapons map[string]WeaponState - Companions map[string]CompanionState - Thoughts map[string]ThoughtState - DeckCharacters map[string]DeckCharacterState - Decks map[DeckKey]DeckState - TripleDecks map[DeckKey]TripleDeckState - Quests map[int32]UserQuestState - QuestMissions map[QuestMissionKey]UserQuestMissionState - Missions map[int32]UserMissionState - WeaponStories map[int32]WeaponStoryState - Gimmick GimmickState - CageOrnamentRewards map[int32]CageOrnamentRewardState - ConsumableItems map[int32]int32 - Materials map[int32]int32 - Parts map[string]PartsState - PartsGroupNotes map[int32]PartsGroupNoteState - PartsPresets map[int32]PartsPresetState - PartsPresetTags map[int32]PartsPresetTagState - PartsStatusSubs map[PartsStatusSubKey]PartsStatusSubState - ImportantItems map[int32]int32 - CostumeActiveSkills map[string]CostumeActiveSkillState - WeaponSkills map[string][]WeaponSkillState // key: userWeaponUuid - WeaponAbilities map[string][]WeaponAbilityState // key: userWeaponUuid - WeaponAwakens map[string]WeaponAwakenState // key: userWeaponUuid - DeckTypeNotes map[model.DeckType]DeckTypeNoteState - WeaponNotes map[int32]WeaponNoteState - DeckSubWeapons map[string][]string - DeckParts map[string][]string - NaviCutInPlayed map[int32]bool - ViewedMovies map[int32]int64 - ContentsStories map[int32]int64 - DrawnOmikuji map[int32]int64 - PremiumItems map[int32]int64 - DokanConfirmed map[int32]bool - PortalCageStatus PortalCageStatusState - GuerrillaFreeOpen GuerrillaFreeOpenState - ShopItems map[int32]UserShopItemState - ShopReplaceable UserShopReplaceableState - ShopReplaceableLineup map[int32]UserShopReplaceableLineupState + Characters map[int32]CharacterState + Costumes map[string]CostumeState + Weapons map[string]WeaponState + Companions map[string]CompanionState + Thoughts map[string]ThoughtState + DeckCharacters map[string]DeckCharacterState + Decks map[DeckKey]DeckState + TripleDecks map[DeckKey]TripleDeckState + Quests map[int32]UserQuestState + QuestMissions map[QuestMissionKey]UserQuestMissionState + Missions map[int32]UserMissionState + WeaponStories map[int32]WeaponStoryState + Gimmick GimmickState + CageOrnamentRewards map[int32]CageOrnamentRewardState + TowerAccumulationRewards map[int32]TowerAccumulationRewardState + ConsumableItems map[int32]int32 + Materials map[int32]int32 + Parts map[string]PartsState + PartsGroupNotes map[int32]PartsGroupNoteState + PartsPresets map[int32]PartsPresetState + PartsPresetTags map[int32]PartsPresetTagState + PartsStatusSubs map[PartsStatusSubKey]PartsStatusSubState + ImportantItems map[int32]int32 + CostumeActiveSkills map[string]CostumeActiveSkillState + WeaponSkills map[string][]WeaponSkillState // key: userWeaponUuid + WeaponAbilities map[string][]WeaponAbilityState // key: userWeaponUuid + WeaponAwakens map[string]WeaponAwakenState // key: userWeaponUuid + DeckTypeNotes map[model.DeckType]DeckTypeNoteState + WeaponNotes map[int32]WeaponNoteState + DeckSubWeapons map[string][]string + DeckParts map[string][]string + NaviCutInPlayed map[int32]bool + ViewedMovies map[int32]int64 + ContentsStories map[int32]int64 + DrawnOmikuji map[int32]int64 + PremiumItems map[int32]int64 + DokanConfirmed map[int32]bool + PortalCageStatus PortalCageStatusState + GuerrillaFreeOpen GuerrillaFreeOpenState + ShopItems map[int32]UserShopItemState + ShopReplaceable UserShopReplaceableState + ShopReplaceableLineup map[int32]UserShopReplaceableLineupState Explore ExploreState ExploreScores map[int32]ExploreScoreState @@ -192,6 +193,9 @@ func (u *UserState) EnsureMaps() { if u.CageOrnamentRewards == nil { u.CageOrnamentRewards = make(map[int32]CageOrnamentRewardState) } + if u.TowerAccumulationRewards == nil { + u.TowerAccumulationRewards = make(map[int32]TowerAccumulationRewardState) + } if u.ConsumableItems == nil { u.ConsumableItems = make(map[int32]int32) } @@ -868,6 +872,12 @@ type CageOrnamentRewardState struct { LatestVersion int64 } +type TowerAccumulationRewardState struct { + EventQuestChapterId int32 + LatestRewardReceiveQuestMissionClearCount 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 628b7f8..856b629 100644 --- a/server/internal/userdata/changed_tables.go +++ b/server/internal/userdata/changed_tables.go @@ -263,6 +263,9 @@ func ChangedTables(before, after *store.UserState) []string { if !mapsEqualStruct(before.CageOrnamentRewards, after.CageOrnamentRewards) { add("IUserCageOrnamentReward") } + if !mapsEqualStruct(before.TowerAccumulationRewards, after.TowerAccumulationRewards) { + add("IUserEventQuestTowerAccumulationReward") + } if !mapsEqualStruct(before.BigHuntMaxScores, after.BigHuntMaxScores) { add("IUserBigHuntMaxScore") @@ -426,6 +429,8 @@ func keyFieldsForTable(table string) []string { return []string{"userId", "userPartsPresetTagNumber"} case "IUserCageOrnamentReward": return []string{"userId", "cageOrnamentId"} + case "IUserEventQuestTowerAccumulationReward": + return []string{"userId", "eventQuestChapterId"} case "IUserAutoSaleSettingDetail": return []string{"userId", "possessionAutoSaleItemType"} case "IUserCharacterRebirth": diff --git a/server/internal/userdata/proj_quest.go b/server/internal/userdata/proj_quest.go index 93f536a..5c3a7bf 100644 --- a/server/internal/userdata/proj_quest.go +++ b/server/internal/userdata/proj_quest.go @@ -219,11 +219,32 @@ func init() { s, _ := utils.EncodeJSONMaps(records...) return s }) + register("IUserEventQuestTowerAccumulationReward", func(user store.UserState) string { + if len(user.TowerAccumulationRewards) == 0 { + return "[]" + } + ids := make([]int, 0, len(user.TowerAccumulationRewards)) + for id := range user.TowerAccumulationRewards { + ids = append(ids, int(id)) + } + sort.Ints(ids) + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + st := user.TowerAccumulationRewards[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "eventQuestChapterId": st.EventQuestChapterId, + "latestRewardReceiveQuestMissionClearCount": st.LatestRewardReceiveQuestMissionClearCount, + "latestVersion": st.LatestVersion, + }) + } + s, _ := utils.EncodeJSONMaps(records...) + return s + }) registerStatic( "IUserEventQuestDailyGroupCompleteReward", "IUserEventQuestLabyrinthSeason", "IUserEventQuestLabyrinthStage", - "IUserEventQuestTowerAccumulationReward", "IUserQuestReplayFlowRewardGroup", "IUserQuestAutoOrbit", "IUserQuestSceneChoice", diff --git a/server/migrations/20260516172941_add_user_tower_accumulation_rewards.sql b/server/migrations/20260516172941_add_user_tower_accumulation_rewards.sql new file mode 100644 index 0000000..73daba2 --- /dev/null +++ b/server/migrations/20260516172941_add_user_tower_accumulation_rewards.sql @@ -0,0 +1,11 @@ +-- +goose Up +CREATE TABLE user_event_quest_tower_accumulation_rewards ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + event_quest_chapter_id INTEGER NOT NULL, + latest_reward_receive_quest_mission_clear_count INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, event_quest_chapter_id) +); + +-- +goose Down +DROP TABLE IF EXISTS user_event_quest_tower_accumulation_rewards;