From dc7c1df4fd7b72c0cd620cea19be437dd04601b4 Mon Sep 17 00:00:00 2001 From: Ilya Groshev Date: Mon, 25 May 2026 09:31:53 +0300 Subject: [PATCH] Add campaign bonuses; fix parts variant/sub-stat grants and menu-pick quest resume state --- server/internal/campaign/catalog.go | 170 +++++++++++++++++++++ server/internal/campaign/enhance.go | 113 ++++++++++++++ server/internal/campaign/modifier.go | 57 +++++++ server/internal/campaign/quest.go | 85 +++++++++++ server/internal/campaign/target.go | 101 ++++++++++++ server/internal/masterdata/parts.go | 4 + server/internal/masterdata/quest.go | 15 ++ server/internal/questflow/bighunt_quest.go | 11 +- server/internal/questflow/campaign.go | 56 +++++++ server/internal/questflow/event_quest.go | 11 +- server/internal/questflow/extra_quest.go | 11 +- server/internal/questflow/handler.go | 38 ++++- server/internal/questflow/quest.go | 17 ++- server/internal/questflow/rewards.go | 32 ++-- server/internal/runtime/build.go | 9 +- server/internal/runtime/holder.go | 17 +-- server/internal/service/costume.go | 9 +- server/internal/service/parts.go | 16 +- server/internal/service/weapon.go | 17 ++- server/internal/store/helpers.go | 85 +++++++++-- server/internal/userdata/proj_quest.go | 20 ++- 21 files changed, 825 insertions(+), 69 deletions(-) create mode 100644 server/internal/campaign/catalog.go create mode 100644 server/internal/campaign/enhance.go create mode 100644 server/internal/campaign/modifier.go create mode 100644 server/internal/campaign/quest.go create mode 100644 server/internal/campaign/target.go create mode 100644 server/internal/questflow/campaign.go diff --git a/server/internal/campaign/catalog.go b/server/internal/campaign/catalog.go new file mode 100644 index 0000000..75727ab --- /dev/null +++ b/server/internal/campaign/catalog.go @@ -0,0 +1,170 @@ +package campaign + +import ( + "fmt" + + "lunar-tear/server/internal/masterdata" + "lunar-tear/server/internal/utils" +) + +type Catalog struct { + enhance []enhanceRow + quest []questRow +} + +type enhanceRow struct { + effectType EnhanceCampaignEffectType + effectValue int32 + targets []enhanceMatch + startMillis int64 + endMillis int64 + userStatus TargetUserStatusType +} + +type enhanceMatch struct { + t EnhanceCampaignTargetType + v int32 +} + +type questRow struct { + effectType QuestCampaignEffectType + effectValue int32 + bonusItems []BonusDrop + targets []questMatch + startMillis int64 + endMillis int64 + userStatus TargetUserStatusType +} + +type questMatch struct { + t QuestCampaignTargetType + v int32 +} + +func Load() (*Catalog, error) { + enhance, err := loadEnhanceRows() + if err != nil { + return nil, fmt.Errorf("load enhance campaigns: %w", err) + } + quest, err := loadQuestRows() + if err != nil { + return nil, fmt.Errorf("load quest campaigns: %w", err) + } + return &Catalog{enhance: enhance, quest: quest}, nil +} + +func (c *Catalog) EnhanceCount() int { return len(c.enhance) } +func (c *Catalog) QuestCount() int { return len(c.quest) } + +func loadEnhanceRows() ([]enhanceRow, error) { + campaigns, err := utils.ReadTable[masterdata.EntityMEnhanceCampaign]("m_enhance_campaign") + if err != nil { + return nil, err + } + targets, err := utils.ReadTable[masterdata.EntityMEnhanceCampaignTargetGroup]("m_enhance_campaign_target_group") + if err != nil { + return nil, err + } + + byGroup := make(map[int32][]enhanceMatch, len(targets)) + for _, t := range targets { + byGroup[t.EnhanceCampaignTargetGroupId] = append(byGroup[t.EnhanceCampaignTargetGroupId], enhanceMatch{ + t: EnhanceCampaignTargetType(t.EnhanceCampaignTargetType), + v: t.EnhanceCampaignTargetValue, + }) + } + + rows := make([]enhanceRow, 0, len(campaigns)) + for _, c := range campaigns { + grp := byGroup[c.EnhanceCampaignTargetGroupId] + if len(grp) == 0 { + continue + } + rows = append(rows, enhanceRow{ + effectType: EnhanceCampaignEffectType(c.EnhanceCampaignEffectType), + effectValue: c.EnhanceCampaignEffectValue, + targets: grp, + startMillis: c.StartDatetime, + endMillis: c.EndDatetime, + userStatus: TargetUserStatusType(c.TargetUserStatusType), + }) + } + return rows, nil +} + +func loadQuestRows() ([]questRow, error) { + campaigns, err := utils.ReadTable[masterdata.EntityMQuestCampaign]("m_quest_campaign") + if err != nil { + return nil, err + } + targets, err := utils.ReadTable[masterdata.EntityMQuestCampaignTargetGroup]("m_quest_campaign_target_group") + if err != nil { + return nil, err + } + effects, err := utils.ReadTable[masterdata.EntityMQuestCampaignEffectGroup]("m_quest_campaign_effect_group") + if err != nil { + return nil, err + } + itemGroups, err := utils.ReadTable[masterdata.EntityMQuestCampaignTargetItemGroup]("m_quest_campaign_target_item_group") + if err != nil { + return nil, err + } + + targetsByGroup := make(map[int32][]questMatch, len(targets)) + for _, t := range targets { + targetsByGroup[t.QuestCampaignTargetGroupId] = append(targetsByGroup[t.QuestCampaignTargetGroupId], questMatch{ + t: QuestCampaignTargetType(t.QuestCampaignTargetType), + v: t.QuestCampaignTargetValue, + }) + } + + bonusByGroup := make(map[int32][]BonusDrop, len(itemGroups)) + for _, ig := range itemGroups { + bonusByGroup[ig.QuestCampaignTargetItemGroupId] = append(bonusByGroup[ig.QuestCampaignTargetItemGroupId], BonusDrop{ + PossessionType: ig.PossessionType, + PossessionId: ig.PossessionId, + Count: ig.Count, + }) + } + + effectByGroup := make(map[int32]masterdata.EntityMQuestCampaignEffectGroup, len(effects)) + for _, e := range effects { + effectByGroup[e.QuestCampaignEffectGroupId] = e + } + + rows := make([]questRow, 0, len(campaigns)) + for _, c := range campaigns { + grp := targetsByGroup[c.QuestCampaignTargetGroupId] + if len(grp) == 0 { + continue + } + eff, ok := effectByGroup[c.QuestCampaignEffectGroupId] + if !ok { + continue + } + rows = append(rows, questRow{ + effectType: QuestCampaignEffectType(eff.QuestCampaignEffectType), + effectValue: eff.QuestCampaignEffectValue, + bonusItems: bonusByGroup[eff.QuestCampaignTargetItemGroupId], + targets: grp, + startMillis: c.StartDatetime, + endMillis: c.EndDatetime, + userStatus: TargetUserStatusType(c.TargetUserStatusType), + }) + } + return rows, nil +} + +func (r enhanceRow) isActive(f Filter) bool { + if f.NowMillis < r.startMillis || f.NowMillis > r.endMillis { + return false + } + return r.userStatus == TargetUserStatusAll || r.userStatus == f.UserStatus +} + +func (r questRow) isActive(f Filter) bool { + if f.NowMillis < r.startMillis || f.NowMillis > r.endMillis { + return false + } + return r.userStatus == TargetUserStatusAll || r.userStatus == f.UserStatus +} diff --git a/server/internal/campaign/enhance.go b/server/internal/campaign/enhance.go new file mode 100644 index 0000000..28a8ea1 --- /dev/null +++ b/server/internal/campaign/enhance.go @@ -0,0 +1,113 @@ +package campaign + +func (c *Catalog) PartsRateBonus(t PartsTarget, f Filter) RateBonus { + var out RateBonus + for _, r := range c.enhance { + if !r.isActive(f) { + continue + } + if !matchesParts(r.targets, t) { + continue + } + out = applyEnhanceEffect(out, r) + } + return out +} + +func (c *Catalog) CostumeExpBonus(t CostumeTarget, f Filter) ExpBonus { + var sum int32 + for _, r := range c.enhance { + if !r.isActive(f) || r.effectType != EnhanceEffectAdditionalPerm { + continue + } + if matchesCostume(r.targets, t) { + sum += r.effectValue + } + } + return ExpBonus{bonusPermil: sum} +} + +func (c *Catalog) WeaponExpBonus(t WeaponTarget, f Filter) ExpBonus { + var sum int32 + for _, r := range c.enhance { + if !r.isActive(f) || r.effectType != EnhanceEffectAdditionalPerm { + continue + } + if matchesWeapon(r.targets, t) { + sum += r.effectValue + } + } + return ExpBonus{bonusPermil: sum} +} + +func applyEnhanceEffect(b RateBonus, r enhanceRow) RateBonus { + switch r.effectType { + case EnhanceEffectProbability: + b.override = r.effectValue + case EnhanceEffectAdditionalPerm: + b.bonusPermil += r.effectValue + } + return b +} + +func matchesParts(targets []enhanceMatch, t PartsTarget) bool { + for _, m := range targets { + switch m.t { + case EnhanceTargetPartsAll: + return true + case EnhanceTargetPartsSeriesId: + if m.v == t.PartsGroupId { + return true + } + case EnhanceTargetPartsId: + if m.v == t.PartsId { + return true + } + } + } + return false +} + +func matchesCostume(targets []enhanceMatch, t CostumeTarget) bool { + for _, m := range targets { + switch m.t { + case EnhanceTargetCostumeAll: + return true + case EnhanceTargetCostumeCharacterId: + if m.v == t.CharacterId { + return true + } + case EnhanceTargetCostumeSkillfulWeapon: + if m.v == t.SkillfulWeaponType { + return true + } + case EnhanceTargetCostumeId: + if m.v == t.CostumeId { + return true + } + } + } + return false +} + +func matchesWeapon(targets []enhanceMatch, t WeaponTarget) bool { + for _, m := range targets { + switch m.t { + case EnhanceTargetWeaponAll: + return true + case EnhanceTargetWeaponTypeId: + if m.v == t.WeaponType { + return true + } + case EnhanceTargetWeaponAttributeTypeId: + if m.v == t.AttributeType { + return true + } + case EnhanceTargetWeaponId: + if m.v == t.WeaponId { + return true + } + } + } + return false +} diff --git a/server/internal/campaign/modifier.go b/server/internal/campaign/modifier.go new file mode 100644 index 0000000..1cbdc2e --- /dev/null +++ b/server/internal/campaign/modifier.go @@ -0,0 +1,57 @@ +package campaign + +type RateBonus struct { + override int32 + bonusPermil int32 +} + +func (b RateBonus) Apply(basePermil int32) int32 { + base := basePermil + if b.override > 0 { + base = b.override + } + return clampPermil(base + b.bonusPermil) +} + +type ExpBonus struct { + bonusPermil int32 +} + +func (b ExpBonus) Apply(base int32) int32 { + return base * (1000 + b.bonusPermil) / 1000 +} + +type StaminaMul struct { + permil int32 +} + +func (m StaminaMul) Apply(base int32) int32 { + if m.permil == 1000 { + return base + } + return base * m.permil / 1000 +} + +type DropRateMul struct { + bonusPermil int32 +} + +func (m DropRateMul) Apply(base int32) int32 { + return (base*(1000+m.bonusPermil) + 999) / 1000 +} + +type BonusDrop struct { + PossessionType int32 + PossessionId int32 + Count int32 +} + +func clampPermil(v int32) int32 { + if v < 0 { + return 0 + } + if v > 1000 { + return 1000 + } + return v +} diff --git a/server/internal/campaign/quest.go b/server/internal/campaign/quest.go new file mode 100644 index 0000000..7005e1b --- /dev/null +++ b/server/internal/campaign/quest.go @@ -0,0 +1,85 @@ +package campaign + +func (c *Catalog) QuestStamina(t QuestTarget, f Filter) StaminaMul { + return questPermilMin(c.quest, QuestEffectStaminaConsume, t, f) +} + +func (c *Catalog) QuestDropRate(t QuestTarget, f Filter) DropRateMul { + var best int32 + for _, r := range c.quest { + if !r.isActive(f) || r.effectType != QuestEffectDropRate { + continue + } + if !matchesQuest(r.targets, t) { + continue + } + if r.effectValue > best { + best = r.effectValue + } + } + return DropRateMul{bonusPermil: best} +} + +func (c *Catalog) QuestBonusDrops(t QuestTarget, f Filter) []BonusDrop { + var out []BonusDrop + for _, r := range c.quest { + if !r.isActive(f) || r.effectType != QuestEffectDropItemAdd { + continue + } + if !matchesQuest(r.targets, t) { + continue + } + out = append(out, r.bonusItems...) + } + return out +} + +func questPermilMin(rows []questRow, want QuestCampaignEffectType, t QuestTarget, f Filter) StaminaMul { + min := int32(1000) + for _, r := range rows { + if !r.isActive(f) || r.effectType != want { + continue + } + if !matchesQuest(r.targets, t) { + continue + } + if r.effectValue < min { + min = r.effectValue + } + } + return StaminaMul{permil: min} +} + +func matchesQuest(targets []questMatch, t QuestTarget) bool { + for _, m := range targets { + switch m.t { + case QuestTargetWholeQuest: + return true + case QuestTargetQuestType: + if int32(t.QuestType) == m.v { + return true + } + case QuestTargetEventQuestType: + if t.QuestType == QuestTypeEventQuest && t.EventQuestType == m.v { + return true + } + case QuestTargetMainQuestChapterId: + if t.QuestType == QuestTypeMainQuest && t.ChapterId == m.v { + return true + } + case QuestTargetMainQuestQuestId: + if t.QuestType == QuestTypeMainQuest && t.QuestId == m.v { + return true + } + case QuestTargetSubQuestChapterId: + if t.QuestType == QuestTypeEventQuest && t.ChapterId == m.v { + return true + } + case QuestTargetSubQuestQuestId: + if t.QuestType == QuestTypeEventQuest && t.QuestId == m.v { + return true + } + } + } + return false +} diff --git a/server/internal/campaign/target.go b/server/internal/campaign/target.go new file mode 100644 index 0000000..410c561 --- /dev/null +++ b/server/internal/campaign/target.go @@ -0,0 +1,101 @@ +package campaign + +import "lunar-tear/server/internal/model" + +type EnhanceCampaignEffectType int32 + +const ( + EnhanceEffectUnknown EnhanceCampaignEffectType = 0 + EnhanceEffectProbability EnhanceCampaignEffectType = 1 + EnhanceEffectAdditionalPerm EnhanceCampaignEffectType = 2 +) + +type EnhanceCampaignTargetType int32 + +const ( + EnhanceTargetUnknown EnhanceCampaignTargetType = 0 + EnhanceTargetCostumeAll EnhanceCampaignTargetType = 1 + EnhanceTargetWeaponAll EnhanceCampaignTargetType = 2 + EnhanceTargetPartsAll EnhanceCampaignTargetType = 3 + EnhanceTargetCostumeCharacterId EnhanceCampaignTargetType = 11 + EnhanceTargetCostumeSkillfulWeapon EnhanceCampaignTargetType = 12 + EnhanceTargetCostumeId EnhanceCampaignTargetType = 13 + EnhanceTargetWeaponTypeId EnhanceCampaignTargetType = 21 + EnhanceTargetWeaponAttributeTypeId EnhanceCampaignTargetType = 22 + EnhanceTargetWeaponId EnhanceCampaignTargetType = 23 + EnhanceTargetPartsSeriesId EnhanceCampaignTargetType = 31 + EnhanceTargetPartsId EnhanceCampaignTargetType = 32 +) + +type QuestCampaignEffectType int32 + +const ( + QuestEffectUnknown QuestCampaignEffectType = 0 + QuestEffectDropRate QuestCampaignEffectType = 1 + QuestEffectDropCount QuestCampaignEffectType = 2 + QuestEffectStaminaConsume QuestCampaignEffectType = 3 + QuestEffectClearRewardGold QuestCampaignEffectType = 4 + QuestEffectDropItemAdd QuestCampaignEffectType = 5 +) + +type QuestCampaignTargetType int32 + +const ( + QuestTargetUnknown QuestCampaignTargetType = 0 + QuestTargetWholeQuest QuestCampaignTargetType = 1 + QuestTargetQuestType QuestCampaignTargetType = 2 + QuestTargetEventQuestType QuestCampaignTargetType = 3 + QuestTargetMainQuestChapterId QuestCampaignTargetType = 4 + QuestTargetMainQuestQuestId QuestCampaignTargetType = 5 + QuestTargetSubQuestChapterId QuestCampaignTargetType = 6 + QuestTargetSubQuestQuestId QuestCampaignTargetType = 7 +) + +type QuestType int32 + +const ( + QuestTypeUnknown QuestType = 0 + QuestTypeMainQuest QuestType = 1 + QuestTypeEventQuest QuestType = 2 + QuestTypeExtraQuest QuestType = 3 + QuestTypeBigHunt QuestType = 4 +) + +type TargetUserStatusType int32 + +const ( + TargetUserStatusUnknown TargetUserStatusType = 0 + TargetUserStatusAll TargetUserStatusType = 1 + TargetUserStatusComeback TargetUserStatusType = 2 + TargetUserStatusBeginner TargetUserStatusType = 3 +) + +type Filter struct { + NowMillis int64 + UserStatus TargetUserStatusType +} + +type PartsTarget struct { + PartsId int32 + PartsGroupId int32 + Rarity model.RarityType +} + +type CostumeTarget struct { + CostumeId int32 + CharacterId int32 + SkillfulWeaponType int32 +} + +type WeaponTarget struct { + WeaponId int32 + WeaponType int32 + AttributeType int32 +} + +type QuestTarget struct { + QuestId int32 + QuestType QuestType + EventQuestType int32 + ChapterId int32 +} diff --git a/server/internal/masterdata/parts.go b/server/internal/masterdata/parts.go index b53686e..d903122 100644 --- a/server/internal/masterdata/parts.go +++ b/server/internal/masterdata/parts.go @@ -158,5 +158,9 @@ func buildPartsStatusMain() (map[int32]PartsStatusMainDef, map[int32][]int32) { id++ } } + // Newer parts groups (PartsGroupId 401-490) use PartsStatusSubLotteryGroupId + // 11/12 for rarities 10/20 instead of 1/2. Same stat pools — alias them. + pool[11] = pool[1] + pool[12] = pool[2] return defs, pool } diff --git a/server/internal/masterdata/quest.go b/server/internal/masterdata/quest.go index 5649d07..7e0143f 100644 --- a/server/internal/masterdata/quest.go +++ b/server/internal/masterdata/quest.go @@ -37,6 +37,8 @@ type QuestCatalog struct { RoutesBySeason map[int32][]int32 RouteCompletionQuestId map[int32]int32 BattleOnlyTargetSceneByQuestId map[int32]int32 + MainQuestChapterIdByQuestId map[int32]int32 + EventQuestTypeByChapterId map[int32]int32 UserExpThresholds []int32 CharacterExpThresholds []int32 @@ -382,12 +384,23 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) { chapterBySequenceId[chapter.MainQuestSequenceGroupId] = chapter } routeIdByQuestId := make(map[int32]int32) + mainQuestChapterIdByQuestId := make(map[int32]int32) for _, sequence := range sequences { if chapter, ok := chapterBySequenceId[sequence.MainQuestSequenceId]; ok { routeIdByQuestId[sequence.QuestId] = chapter.MainQuestRouteId + mainQuestChapterIdByQuestId[sequence.QuestId] = chapter.MainQuestChapterId } } + eventChapters, err := utils.ReadTable[EntityMEventQuestChapter]("m_event_quest_chapter") + if err != nil { + return nil, fmt.Errorf("load event quest chapter table: %w", err) + } + eventQuestTypeByChapterId := make(map[int32]int32, len(eventChapters)) + for _, ec := range eventChapters { + eventQuestTypeByChapterId[ec.EventQuestChapterId] = ec.EventQuestType + } + sortedChapters := make([]EntityMMainQuestChapter, len(chapters)) copy(sortedChapters, chapters) sort.Slice(sortedChapters, func(i, j int) bool { @@ -589,6 +602,8 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) { RoutesBySeason: routesBySeason, RouteCompletionQuestId: routeCompletionQuestId, BattleOnlyTargetSceneByQuestId: battleOnlyTargetSceneByQuestId, + MainQuestChapterIdByQuestId: mainQuestChapterIdByQuestId, + EventQuestTypeByChapterId: eventQuestTypeByChapterId, UserExpThresholds: BuildExpThresholds(paramMapRows, 1), CharacterExpThresholds: BuildExpThresholds(paramMapRows, 31), diff --git a/server/internal/questflow/bighunt_quest.go b/server/internal/questflow/bighunt_quest.go index 8c41d47..f2a4dac 100644 --- a/server/internal/questflow/bighunt_quest.go +++ b/server/internal/questflow/bighunt_quest.go @@ -17,7 +17,8 @@ func (h *QuestHandler) HandleBigHuntQuestStart(user *store.UserState, questId, u if quest.Stamina > 0 { maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 - store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis) + stamina := h.staminaWithCampaign(quest.Stamina, h.targetForBigHunt(questId), nowMillis) + store.ConsumeStamina(user, stamina, maxMillis, nowMillis) } questState := user.Quests[questId] @@ -33,13 +34,15 @@ func (h *QuestHandler) HandleBigHuntQuestFinish(user *store.UserState, questId i panic(fmt.Sprintf("unknown questId=%d for HandleBigHuntQuestFinish", questId)) } - outcome := h.evaluateFinishOutcome(user, questId) + target := h.targetForBigHunt(questId) + outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis) if !isRetired { h.applyQuestVictory(user, questId, &outcome, nowMillis, false) } - if isRetired && !isAnnihilated && quest.Stamina > 1 { - refund := quest.Stamina - 1 + consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis) + if isRetired && !isAnnihilated && consumed > 1 { + refund := consumed - 1 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) } diff --git a/server/internal/questflow/campaign.go b/server/internal/questflow/campaign.go new file mode 100644 index 0000000..203175c --- /dev/null +++ b/server/internal/questflow/campaign.go @@ -0,0 +1,56 @@ +package questflow + +import ( + "lunar-tear/server/internal/campaign" + "lunar-tear/server/internal/model" +) + +func (h *QuestHandler) targetForMain(questId int32) campaign.QuestTarget { + return campaign.QuestTarget{ + QuestId: questId, + QuestType: campaign.QuestTypeMainQuest, + ChapterId: h.MainQuestChapterIdByQuestId[questId], + } +} + +func (h *QuestHandler) targetForEvent(eventChapterId, questId int32) campaign.QuestTarget { + return campaign.QuestTarget{ + QuestId: questId, + QuestType: campaign.QuestTypeEventQuest, + EventQuestType: h.EventQuestTypeByChapterId[eventChapterId], + ChapterId: eventChapterId, + } +} + +func (h *QuestHandler) targetForExtra(questId int32) campaign.QuestTarget { + return campaign.QuestTarget{QuestId: questId, QuestType: campaign.QuestTypeExtraQuest} +} + +func (h *QuestHandler) targetForBigHunt(questId int32) campaign.QuestTarget { + return campaign.QuestTarget{QuestId: questId, QuestType: campaign.QuestTypeBigHunt} +} + +func (h *QuestHandler) campaignFilter(nowMillis int64) campaign.Filter { + return campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll} +} + +func (h *QuestHandler) staminaWithCampaign(baseStamina int32, t campaign.QuestTarget, nowMillis int64) int32 { + if h.Campaigns == nil { + return baseStamina + } + return h.Campaigns.QuestStamina(t, h.campaignFilter(nowMillis)).Apply(baseStamina) +} + +func (h *QuestHandler) appendBonusDrops(drops []RewardGrant, t campaign.QuestTarget, nowMillis int64) []RewardGrant { + if h.Campaigns == nil { + return drops + } + for _, bd := range h.Campaigns.QuestBonusDrops(t, h.campaignFilter(nowMillis)) { + drops = append(drops, RewardGrant{ + PossessionType: model.PossessionType(bd.PossessionType), + PossessionId: bd.PossessionId, + Count: bd.Count, + }) + } + return drops +} diff --git a/server/internal/questflow/event_quest.go b/server/internal/questflow/event_quest.go index e821751..80bfff6 100644 --- a/server/internal/questflow/event_quest.go +++ b/server/internal/questflow/event_quest.go @@ -18,7 +18,8 @@ func (h *QuestHandler) HandleEventQuestStart(user *store.UserState, eventQuestCh if quest.Stamina > 0 { maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 - store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis) + stamina := h.staminaWithCampaign(quest.Stamina, h.targetForEvent(eventQuestChapterId, questId), nowMillis) + store.ConsumeStamina(user, stamina, maxMillis, nowMillis) } questState := user.Quests[questId] @@ -42,14 +43,16 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC panic(fmt.Sprintf("unknown questId=%d for HandleEventQuestFinish", questId)) } - outcome := h.evaluateFinishOutcome(user, questId) + target := h.targetForEvent(eventQuestChapterId, questId) + outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis) if !isRetired { h.applyQuestVictory(user, questId, &outcome, nowMillis, false) h.recordSideStoryLimitContentStatus(user, questId, nowMillis) } - if isRetired && !isAnnihilated && quest.Stamina > 1 { - refund := quest.Stamina - 1 + consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis) + if isRetired && !isAnnihilated && consumed > 1 { + refund := consumed - 1 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) } diff --git a/server/internal/questflow/extra_quest.go b/server/internal/questflow/extra_quest.go index c454404..e3e5d34 100644 --- a/server/internal/questflow/extra_quest.go +++ b/server/internal/questflow/extra_quest.go @@ -18,7 +18,8 @@ func (h *QuestHandler) HandleExtraQuestStart(user *store.UserState, questId, use if quest.Stamina > 0 { maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 - store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis) + stamina := h.staminaWithCampaign(quest.Stamina, h.targetForExtra(questId), nowMillis) + store.ConsumeStamina(user, stamina, maxMillis, nowMillis) } questState := user.Quests[questId] @@ -40,13 +41,15 @@ func (h *QuestHandler) HandleExtraQuestFinish(user *store.UserState, questId int panic(fmt.Sprintf("unknown questId=%d for HandleExtraQuestFinish", questId)) } - outcome := h.evaluateFinishOutcome(user, questId) + target := h.targetForExtra(questId) + outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis) if !isRetired { h.applyQuestVictory(user, questId, &outcome, nowMillis, false) } - if isRetired && !isAnnihilated && quest.Stamina > 1 { - refund := quest.Stamina - 1 + consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis) + if isRetired && !isAnnihilated && consumed > 1 { + refund := consumed - 1 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) } diff --git a/server/internal/questflow/handler.go b/server/internal/questflow/handler.go index 1e31ab9..b426658 100644 --- a/server/internal/questflow/handler.go +++ b/server/internal/questflow/handler.go @@ -1,6 +1,9 @@ package questflow import ( + "sort" + + "lunar-tear/server/internal/campaign" "lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/model" "lunar-tear/server/internal/store" @@ -28,9 +31,10 @@ type QuestHandler struct { Config *masterdata.GameConfig Granter *store.PossessionGranter SideStoryChapterByEventQuestId map[int32]int32 + Campaigns *campaign.Catalog } -func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig, sideStory *masterdata.SideStoryCatalog) *QuestHandler { +func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig, sideStory *masterdata.SideStoryCatalog, campaigns *campaign.Catalog) *QuestHandler { granter := BuildGranter(catalog) var sideStoryChapters map[int32]int32 if sideStory != nil { @@ -41,6 +45,7 @@ func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameCo Config: config, Granter: granter, SideStoryChapterByEventQuestId: sideStoryChapters, + Campaigns: campaigns, } } @@ -70,12 +75,40 @@ func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter { releaseConditions[groupId] = conds } partsById := make(map[int32]store.PartsRef, len(catalog.PartsById)) + partsVariants := make(map[int32]map[int32][]int32) for id, p := range catalog.PartsById { partsById[id] = store.PartsRef{ PartsGroupId: p.PartsGroupId, + RarityType: p.RarityType, + PartsInitialLotteryId: p.PartsInitialLotteryId, PartsStatusMainLotteryGroupId: p.PartsStatusMainLotteryGroupId, + PartsStatusSubLotteryGroupId: p.PartsStatusSubLotteryGroupId, + } + if partsVariants[p.PartsGroupId] == nil { + partsVariants[p.PartsGroupId] = map[int32][]int32{} + } + partsVariants[p.PartsGroupId][p.RarityType] = append(partsVariants[p.PartsGroupId][p.RarityType], p.PartsId) + } + for _, byRarity := range partsVariants { + for _, ids := range byRarity { + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) } } + + partsSubDefs := make(map[int32]store.PartsStatusSubDef, len(catalog.PartsStatusMainById)) + for id, d := range catalog.PartsStatusMainById { + var fn func(int32) int32 + if f, ok := catalog.FuncResolver.Resolve(d.StatusNumericalFunctionId); ok { + fn = f.Evaluate + } + partsSubDefs[id] = store.PartsStatusSubDef{ + StatusKindType: d.StatusKindType, + StatusCalculationType: d.StatusCalculationType, + StatusChangeInitialValue: d.StatusChangeInitialValue, + StatusFunc: fn, + } + } + return &store.PossessionGranter{ CostumeById: costumeById, WeaponById: weaponById, @@ -84,5 +117,8 @@ func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter { ReleaseConditions: releaseConditions, PartsById: partsById, DefaultPartsStatusMainByLotteryGroup: catalog.DefaultPartsStatusMainByLotteryGroup, + PartsVariantsByGroupRarity: partsVariants, + PartsSubStatusPool: catalog.SubStatusPool, + PartsSubStatusDefs: partsSubDefs, } } diff --git a/server/internal/questflow/quest.go b/server/internal/questflow/quest.go index 482f4ba..74e7526 100644 --- a/server/internal/questflow/quest.go +++ b/server/internal/questflow/quest.go @@ -61,7 +61,8 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i h.initQuestState(user, questId) if quest.Stamina > 0 { - store.ConsumeStamina(user, quest.Stamina, h.MaxStaminaByLevel[user.Status.Level]*1000, nowMillis) + stamina := h.staminaWithCampaign(quest.Stamina, h.targetForMain(questId), nowMillis) + store.ConsumeStamina(user, stamina, h.MaxStaminaByLevel[user.Status.Level]*1000, nowMillis) } questState := user.Quests[questId] @@ -259,7 +260,7 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i h.initQuestState(user, questId) - outcome := h.evaluateFinishOutcome(user, questId) + outcome := h.evaluateFinishOutcome(user, questId, h.targetForMain(questId), nowMillis) wasReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType) wasMenuReplay := user.MainQuest.SavedContext.Active @@ -277,8 +278,9 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i } } - if isRetired && !isAnnihilated && quest.Stamina > 1 { - refund := quest.Stamina - 1 + consumed := h.staminaWithCampaign(quest.Stamina, h.targetForMain(questId), nowMillis) + if isRetired && !isAnnihilated && consumed > 1 { + refund := consumed - 1 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) } @@ -322,18 +324,19 @@ func (h *QuestHandler) HandleQuestSkip(user *store.UserState, questId, skipCount panic(fmt.Sprintf("unknown questId=%d for HandleQuestSkip", questId)) } + target := h.targetForMain(questId) maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 - store.ConsumeStamina(user, skipCount, maxMillis, nowMillis) + perSkipStamina := h.staminaWithCampaign(questDef.Stamina, target, nowMillis) + store.ConsumeStamina(user, perSkipStamina*skipCount, maxMillis, nowMillis) skipTicketId := h.Config.ConsumableItemIdForQuestSkipTicket user.ConsumableItems[skipTicketId] -= skipCount if user.ConsumableItems[skipTicketId] < 0 { user.ConsumableItems[skipTicketId] = 0 } - var allDrops []RewardGrant for range skipCount { - drops := h.computeDropRewards(questDef) + drops := h.computeDropRewards(questDef, target, nowMillis) for _, drop := range drops { h.applyRewardPossession(user, drop.PossessionType, drop.PossessionId, drop.Count, nowMillis) } diff --git a/server/internal/questflow/rewards.go b/server/internal/questflow/rewards.go index 6d9ab90..931bcb2 100644 --- a/server/internal/questflow/rewards.go +++ b/server/internal/questflow/rewards.go @@ -4,6 +4,7 @@ import ( "fmt" "log" + "lunar-tear/server/internal/campaign" "lunar-tear/server/internal/gameutil" "lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/model" @@ -40,7 +41,7 @@ func (h *QuestHandler) firstClearRewardGroupId(user *store.UserState, questDef m return rewardGroupId } -func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int32) FinishOutcome { +func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int32, target campaign.QuestTarget, nowMillis int64) FinishOutcome { outcome := FinishOutcome{} questState, ok := user.Quests[questId] if !ok { @@ -123,25 +124,28 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3 } } - outcome.DropRewards = h.computeDropRewards(questDef) + outcome.DropRewards = h.computeDropRewards(questDef, target, nowMillis) return outcome } -func (h *QuestHandler) computeDropRewards(questDef masterdata.EntityMQuest) []RewardGrant { - if questDef.QuestPickupRewardGroupId == 0 { - return nil - } +func (h *QuestHandler) computeDropRewards(questDef masterdata.EntityMQuest, target campaign.QuestTarget, nowMillis int64) []RewardGrant { var drops []RewardGrant - for _, dropId := range h.PickupRewardIdsByGroupId[questDef.QuestPickupRewardGroupId] { - if bdr, ok := h.BattleDropRewardById[dropId]; ok { - drops = append(drops, RewardGrant{ - PossessionType: model.PossessionType(bdr.PossessionType), - PossessionId: bdr.PossessionId, - Count: bdr.Count, - }) + var dropRate campaign.DropRateMul + if h.Campaigns != nil { + dropRate = h.Campaigns.QuestDropRate(target, h.campaignFilter(nowMillis)) + } + if questDef.QuestPickupRewardGroupId != 0 { + for _, dropId := range h.PickupRewardIdsByGroupId[questDef.QuestPickupRewardGroupId] { + if bdr, ok := h.BattleDropRewardById[dropId]; ok { + drops = append(drops, RewardGrant{ + PossessionType: model.PossessionType(bdr.PossessionType), + PossessionId: bdr.PossessionId, + Count: dropRate.Apply(bdr.Count), + }) + } } } - return drops + return h.appendBonusDrops(drops, target, nowMillis) } func (h *QuestHandler) applyExpRewards(user *store.UserState, questId int32, nowMillis int64) { diff --git a/server/internal/runtime/build.go b/server/internal/runtime/build.go index 835a7e2..c25118f 100644 --- a/server/internal/runtime/build.go +++ b/server/internal/runtime/build.go @@ -4,6 +4,7 @@ import ( "fmt" "log" + "lunar-tear/server/internal/campaign" "lunar-tear/server/internal/gacha" "lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata/memorydb" @@ -35,7 +36,12 @@ func buildCatalogs() (*Catalogs, error) { return nil, fmt.Errorf("load quest catalog: %w", err) } sideStoryCatalog := masterdata.LoadSideStoryCatalog() - questHandler := questflow.NewQuestHandler(questCatalog, gameConfig, sideStoryCatalog) + campaignCatalog, err := campaign.Load() + if err != nil { + return nil, fmt.Errorf("load campaign catalog: %w", err) + } + log.Printf("campaign catalog loaded: %d enhance, %d quest", campaignCatalog.EnhanceCount(), campaignCatalog.QuestCount()) + questHandler := questflow.NewQuestHandler(questCatalog, gameConfig, sideStoryCatalog, campaignCatalog) userdata.SetQuestHandler(questHandler) gachaEntries, medalInfo, err := masterdata.LoadGachaCatalog() @@ -172,6 +178,7 @@ func buildCatalogs() (*Catalogs, error) { BigHunt: bigHuntCatalog, Tower: towerCatalog, Labyrinth: labyrinthCatalog, + Campaign: campaignCatalog, QuestHandler: questHandler, GachaHandler: gachaHandler, }, nil diff --git a/server/internal/runtime/holder.go b/server/internal/runtime/holder.go index eaf4834..5b9bcc3 100644 --- a/server/internal/runtime/holder.go +++ b/server/internal/runtime/holder.go @@ -14,6 +14,7 @@ import ( "sync/atomic" "time" + "lunar-tear/server/internal/campaign" "lunar-tear/server/internal/gacha" "lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata/memorydb" @@ -52,23 +53,17 @@ type Catalogs struct { BigHunt *masterdata.BigHuntCatalog Tower *masterdata.TowerCatalog Labyrinth *masterdata.LabyrinthCatalog + Campaign *campaign.Catalog - // Catalog-derived handlers must rebuild on every reload because they - // embed/cache pointers to specific catalog instances. QuestHandler *questflow.QuestHandler GachaHandler *gacha.GachaHandler } -// Holder owns the current *Catalogs and the bin.e path. Concurrent readers -// call Get(); the single-writer Reload() rebuilds and atomically publishes. type Holder struct { binPath string cur atomic.Pointer[Catalogs] } -// NewHolder reads the binary at binPath, builds the initial catalogs, and -// returns a ready-to-use Holder. Subsequent calls to Reload() re-read the -// same path. func NewHolder(binPath string) (*Holder, error) { h := &Holder{binPath: binPath} if err := h.Reload(); err != nil { @@ -77,9 +72,6 @@ func NewHolder(binPath string) (*Holder, error) { return h, nil } -// Reload re-reads the bin.e from disk, rebuilds every catalog and handler, -// atomically publishes the new snapshot, and bumps the bin.e mtime so client -// caches invalidate (see service/data.go GetLatestMasterDataVersion). func (h *Holder) Reload() error { if err := memorydb.Init(h.binPath); err != nil { return fmt.Errorf("memorydb.Init: %w", err) @@ -91,16 +83,11 @@ func (h *Holder) Reload() error { h.cur.Store(c) now := time.Now() if err := os.Chtimes(h.binPath, now, now); err != nil { - // Non-fatal: the catalogs swapped fine in-memory; clients may take - // longer to invalidate their cached download but server-side state is - // already coherent. log.Printf("[runtime] os.Chtimes(%s) failed (clients may not invalidate cache): %v", h.binPath, err) } return nil } -// Get returns the current snapshot. Safe for concurrent callers; the returned -// pointer is stable for the duration of the caller's use. func (h *Holder) Get() *Catalogs { return h.cur.Load() } diff --git a/server/internal/service/costume.go b/server/internal/service/costume.go index b36dc68..06181b7 100644 --- a/server/internal/service/costume.go +++ b/server/internal/service/costume.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" pb "lunar-tear/server/gen/proto" + "lunar-tear/server/internal/campaign" "lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gameutil" "lunar-tear/server/internal/masterdata" @@ -50,6 +51,12 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque return } + expBonus := cat.Campaign.CostumeExpBonus(campaign.CostumeTarget{ + CostumeId: costume.CostumeId, + CharacterId: cm.CharacterId, + SkillfulWeaponType: cm.SkillfulWeaponType, + }, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll}) + totalExp := int32(0) totalMaterialCount := int32(0) for materialId, count := range req.Materials { @@ -71,7 +78,7 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque if mat.WeaponType != 0 && mat.WeaponType == cm.SkillfulWeaponType { expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000 } - totalExp += expPerUnit * count + totalExp += expBonus.Apply(expPerUnit * count) } if costFunc, ok := catalog.EnhanceCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 { diff --git a/server/internal/service/parts.go b/server/internal/service/parts.go index 037bb23..df3df00 100644 --- a/server/internal/service/parts.go +++ b/server/internal/service/parts.go @@ -7,8 +7,10 @@ import ( "math/rand" pb "lunar-tear/server/gen/proto" + "lunar-tear/server/internal/campaign" "lunar-tear/server/internal/gametime" "lunar-tear/server/internal/masterdata" + "lunar-tear/server/internal/model" "lunar-tear/server/internal/runtime" "lunar-tear/server/internal/store" ) @@ -180,17 +182,23 @@ func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRe successRate = r } } + baseRate := successRate + successRate = cat.Campaign.PartsRateBonus(campaign.PartsTarget{ + PartsId: part.PartsId, + PartsGroupId: partDef.PartsGroupId, + Rarity: model.RarityType(partDef.RarityType), + }, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll}).Apply(baseRate) if rand.Intn(1000) < int(successRate) { part.Level++ isSuccess = true - log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰, cost=%d gold)", - part.PartsId, part.Level-1, part.Level, successRate, goldCost) + log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰ base=%d‰, cost=%d gold)", + part.PartsId, part.Level-1, part.Level, successRate, baseRate, goldCost) grantPartsSubStatuses(catalog, user, req.UserPartsUuid, part, partDef, nowMillis) } else { - log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰, cost=%d gold)", - part.PartsId, part.Level, successRate, goldCost) + log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰ base=%d‰, cost=%d gold)", + part.PartsId, part.Level, successRate, baseRate, goldCost) } part.LatestVersion = nowMillis diff --git a/server/internal/service/weapon.go b/server/internal/service/weapon.go index 4ddb6c8..c68cd1c 100644 --- a/server/internal/service/weapon.go +++ b/server/internal/service/weapon.go @@ -6,6 +6,7 @@ import ( "log" pb "lunar-tear/server/gen/proto" + "lunar-tear/server/internal/campaign" "lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gameutil" "lunar-tear/server/internal/masterdata" @@ -91,6 +92,12 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh return } + expBonus := cat.Campaign.WeaponExpBonus(campaign.WeaponTarget{ + WeaponId: weapon.WeaponId, + WeaponType: wm.WeaponType, + AttributeType: wm.AttributeType, + }, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll}) + totalExp := int32(0) totalMaterialCount := int32(0) for materialId, count := range req.Materials { @@ -112,7 +119,7 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh if mat.WeaponType != 0 && mat.WeaponType == wm.WeaponType { expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000 } - totalExp += expPerUnit * count + totalExp += expBonus.Apply(expPerUnit * count) } if costFunc, ok := catalog.GoldCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 { @@ -702,6 +709,12 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan return } + expBonus := cat.Campaign.WeaponExpBonus(campaign.WeaponTarget{ + WeaponId: weapon.WeaponId, + WeaponType: wm.WeaponType, + AttributeType: wm.AttributeType, + }, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll}) + totalExp := int32(0) consumedCount := int32(0) for _, uuid := range req.MaterialUserWeaponUuids { @@ -722,7 +735,7 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan if matMaster.WeaponType != 0 && matMaster.WeaponType == wm.WeaponType { baseExp = baseExp * config.MaterialSameWeaponExpCoefficientPermil / 1000 } - totalExp += baseExp + totalExp += expBonus.Apply(baseExp) if medals, ok := catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok { for itemId, count := range medals { diff --git a/server/internal/store/helpers.go b/server/internal/store/helpers.go index 95a1a64..1f48db9 100644 --- a/server/internal/store/helpers.go +++ b/server/internal/store/helpers.go @@ -3,6 +3,7 @@ package store import ( "fmt" "log" + "math/rand" "sort" "github.com/google/uuid" @@ -102,7 +103,19 @@ type WeaponStoryReleaseCond struct { type PartsRef struct { PartsGroupId int32 + RarityType int32 + PartsInitialLotteryId int32 PartsStatusMainLotteryGroupId int32 + PartsStatusSubLotteryGroupId int32 +} + +// PartsStatusSubDef carries the per-lottery-id sub-status shape needed at +// grant time. Held here so the store package does not import masterdata. +type PartsStatusSubDef struct { + StatusKindType int32 + StatusCalculationType int32 + StatusChangeInitialValue int32 + StatusFunc func(level int32) int32 } type PossessionGranter struct { @@ -114,6 +127,9 @@ type PossessionGranter struct { PartsById map[int32]PartsRef DefaultPartsStatusMainByLotteryGroup map[int32]int32 + PartsVariantsByGroupRarity map[int32]map[int32][]int32 + PartsSubStatusPool map[int32][]int32 + PartsSubStatusDefs map[int32]PartsStatusSubDef LastChangedStoryWeaponIds []int32 } @@ -184,26 +200,73 @@ func (g *PossessionGranter) GrantCompanion(user *UserState, companionId int32, n } } -func (g *PossessionGranter) GrantParts(user *UserState, partsId int32, nowMillis int64) { - var mainStatId int32 - if ref, ok := g.PartsById[partsId]; ok { - mainStatId = g.DefaultPartsStatusMainByLotteryGroup[ref.PartsStatusMainLotteryGroupId] - if _, exists := user.PartsGroupNotes[ref.PartsGroupId]; !exists { - user.PartsGroupNotes[ref.PartsGroupId] = PartsGroupNoteState{ - PartsGroupId: ref.PartsGroupId, - FirstAcquisitionDatetime: nowMillis, - LatestVersion: nowMillis, - } +func (g *PossessionGranter) GrantParts(user *UserState, requestedPartsId int32, nowMillis int64) { + ref, refOk := g.PartsById[requestedPartsId] + if !refOk { + key := uuid.New().String() + user.Parts[key] = PartsState{ + UserPartsUuid: key, + PartsId: requestedPartsId, + Level: 1, + AcquisitionDatetime: nowMillis, + } + log.Printf("[GrantParts] unknown partsId=%d, granted as-is with no variant roll", requestedPartsId) + return + } + + chosenPartsId := requestedPartsId + chosenRef := ref + if variants := g.PartsVariantsByGroupRarity[ref.PartsGroupId][ref.RarityType]; len(variants) == 5 { + chosenPartsId = variants[rand.Intn(len(variants))] + chosenRef = g.PartsById[chosenPartsId] + } else { + log.Printf("[GrantParts] no 5-variant set for group=%d rarity=%d (have %d), granting requested=%d", ref.PartsGroupId, ref.RarityType, len(variants), requestedPartsId) + } + + mainStatId := g.DefaultPartsStatusMainByLotteryGroup[chosenRef.PartsStatusMainLotteryGroupId] + if _, exists := user.PartsGroupNotes[chosenRef.PartsGroupId]; !exists { + user.PartsGroupNotes[chosenRef.PartsGroupId] = PartsGroupNoteState{ + PartsGroupId: chosenRef.PartsGroupId, + FirstAcquisitionDatetime: nowMillis, + LatestVersion: nowMillis, } } key := uuid.New().String() user.Parts[key] = PartsState{ UserPartsUuid: key, - PartsId: partsId, + PartsId: chosenPartsId, Level: 1, PartsStatusMainId: mainStatId, AcquisitionDatetime: nowMillis, } + + initialCount := chosenRef.PartsInitialLotteryId + pool := g.PartsSubStatusPool[chosenRef.PartsStatusSubLotteryGroupId] + if initialCount > 1 && len(pool) > 0 { + for i := int32(0); i < initialCount-1; i++ { + pickId := pool[rand.Intn(len(pool))] + def, ok := g.PartsSubStatusDefs[pickId] + if !ok { + continue + } + val := def.StatusChangeInitialValue + if def.StatusFunc != nil { + val = def.StatusFunc(1) + } + user.PartsStatusSubs[PartsStatusSubKey{UserPartsUuid: key, StatusIndex: i + 1}] = PartsStatusSubState{ + UserPartsUuid: key, + StatusIndex: i + 1, + PartsStatusSubLotteryId: pickId, + Level: 1, + StatusKindType: def.StatusKindType, + StatusCalculationType: def.StatusCalculationType, + StatusChangeValue: val, + LatestVersion: nowMillis, + } + } + } + + log.Printf("[GrantParts] requested=%d chosen=%d variant=%d group=%d rarity=%d preUnlockedSubs=%d", requestedPartsId, chosenPartsId, initialCount, chosenRef.PartsGroupId, chosenRef.RarityType, initialCount-1) } func (g *PossessionGranter) GrantWeapon(user *UserState, weaponId int32, nowMillis int64) { diff --git a/server/internal/userdata/proj_quest.go b/server/internal/userdata/proj_quest.go index 3c74105..1e73ff7 100644 --- a/server/internal/userdata/proj_quest.go +++ b/server/internal/userdata/proj_quest.go @@ -3,6 +3,7 @@ package userdata import ( "sort" + "lunar-tear/server/internal/model" "lunar-tear/server/internal/store" "lunar-tear/server/internal/utils" ) @@ -13,13 +14,30 @@ func sortedQuestRecords(user store.UserState) []map[string]any { ids = append(ids, int(id)) } sort.Ints(ids) + + var replayQuestId int32 + if user.MainQuest.SavedContext.Active && questHandler != nil { + if scene, ok := questHandler.SceneById[user.MainQuest.ProgressQuestSceneId]; ok { + replayQuestId = scene.QuestId + } + } + records := make([]map[string]any, 0, len(ids)) for _, id := range ids { row := user.Quests[int32(id)] + stateType := row.QuestStateType + if replayQuestId != 0 { + switch { + case int32(id) == replayQuestId: + stateType = model.UserQuestStateTypeActive + case stateType == model.UserQuestStateTypeActive: + stateType = model.UserQuestStateTypeCleared + } + } records = append(records, map[string]any{ "userId": user.UserId, "questId": row.QuestId, - "questStateType": row.QuestStateType, + "questStateType": stateType, "isBattleOnly": row.IsBattleOnly, "latestStartDatetime": row.LatestStartDatetime, "clearCount": row.ClearCount,