Add auto-repeat quest and memoir auto-sell
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled

This commit is contained in:
Ilya Groshev
2026-05-28 10:48:26 +03:00
parent c961fde8ac
commit 63df7d7055
19 changed files with 413 additions and 34 deletions
+3 -2
View File
@@ -35,8 +35,9 @@ func (h *QuestHandler) HandleBigHuntQuestFinish(user *store.UserState, questId i
}
target := h.targetForBigHunt(questId)
outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis)
if !isRetired {
var outcome FinishOutcome
if !isRetired && !isAnnihilated {
outcome = h.evaluateFinishOutcome(user, questId, target, nowMillis)
h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
}
+3 -2
View File
@@ -44,8 +44,9 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC
}
target := h.targetForEvent(eventQuestChapterId, questId)
outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis)
if !isRetired {
var outcome FinishOutcome
if !isRetired && !isAnnihilated {
outcome = h.evaluateFinishOutcome(user, questId, target, nowMillis)
h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
h.recordSideStoryLimitContentStatus(user, questId, nowMillis)
}
+3 -2
View File
@@ -42,8 +42,9 @@ func (h *QuestHandler) HandleExtraQuestFinish(user *store.UserState, questId int
}
target := h.targetForExtra(questId)
outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis)
if !isRetired {
var outcome FinishOutcome
if !isRetired && !isAnnihilated {
outcome = h.evaluateFinishOutcome(user, questId, target, nowMillis)
h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
}
+14 -2
View File
@@ -13,6 +13,7 @@ type RewardGrant struct {
PossessionType model.PossessionType
PossessionId int32
Count int32
IsAutoSale bool
}
type FinishOutcome struct {
@@ -36,7 +37,7 @@ type QuestHandler struct {
}
func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig, sideStory *masterdata.SideStoryCatalog, campaigns *campaign.Catalog, characterRebirth *masterdata.CharacterRebirthCatalog) *QuestHandler {
granter := BuildGranter(catalog)
granter := BuildGranter(catalog, config)
var sideStoryChapters map[int32]int32
if sideStory != nil {
sideStoryChapters = sideStory.ChapterByEventQuestId
@@ -51,7 +52,7 @@ func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameCo
}
}
func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
func BuildGranter(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig) *store.PossessionGranter {
costumeById := make(map[int32]store.CostumeRef, len(catalog.CostumeById))
for id, cm := range catalog.CostumeById {
costumeById[id] = store.CostumeRef{CharacterId: cm.CharacterId}
@@ -111,6 +112,15 @@ func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
}
}
partsSellPriceL1 := make(map[int32]int32, len(catalog.SellPriceByRarity))
for rarity, fn := range catalog.SellPriceByRarity {
partsSellPriceL1[int32(rarity)] = fn.Evaluate(1)
}
var goldItemId int32
if config != nil {
goldItemId = config.ConsumableItemIdForGold
}
return &store.PossessionGranter{
CostumeById: costumeById,
WeaponById: weaponById,
@@ -122,5 +132,7 @@ func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
PartsVariantsByGroupRarity: partsVariants,
PartsSubStatusPool: catalog.SubStatusPool,
PartsSubStatusDefs: partsSubDefs,
PartsSellPriceL1ByRarity: partsSellPriceL1,
GoldConsumableItemId: goldItemId,
}
}
+7 -8
View File
@@ -204,9 +204,8 @@ func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, o
}
questState.IsRewardGranted = true
}
for _, drop := range outcome.DropRewards {
h.applyRewardPossession(user, drop.PossessionType, drop.PossessionId, drop.Count, nowMillis)
}
raritySet, rankSet := parseAutoSaleRules(user.AutoSaleSettings)
h.grantDropRewards(user, outcome.DropRewards, raritySet, rankSet, nowMillis)
for _, reward := range outcome.ReplayFlowFirstClearRewards {
h.applyRewardPossession(user, reward.PossessionType, reward.PossessionId, reward.Count, nowMillis)
}
@@ -260,11 +259,12 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i
h.initQuestState(user, questId)
outcome := h.evaluateFinishOutcome(user, questId, h.targetForMain(questId), nowMillis)
wasReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType)
wasMenuReplay := user.MainQuest.SavedContext.Active
if !isRetired {
var outcome FinishOutcome
if !isRetired && !isAnnihilated {
outcome = h.evaluateFinishOutcome(user, questId, h.targetForMain(questId), nowMillis)
h.applyQuestVictory(user, questId, &outcome, nowMillis, wasReplay)
// A replay-flow finish must NOT move the MainFlow scene pointer: the
@@ -334,12 +334,11 @@ func (h *QuestHandler) HandleQuestSkip(user *store.UserState, questId, skipCount
if user.ConsumableItems[skipTicketId] < 0 {
user.ConsumableItems[skipTicketId] = 0
}
raritySet, rankSet := parseAutoSaleRules(user.AutoSaleSettings)
var allDrops []RewardGrant
for range skipCount {
drops := h.computeDropRewards(questDef, target, nowMillis)
for _, drop := range drops {
h.applyRewardPossession(user, drop.PossessionType, drop.PossessionId, drop.Count, nowMillis)
}
h.grantDropRewards(user, drops, raritySet, rankSet, nowMillis)
allDrops = append(allDrops, drops...)
if questDef.Gold != 0 {
+50
View File
@@ -3,6 +3,8 @@ package questflow
import (
"fmt"
"log"
"strconv"
"strings"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gameutil"
@@ -128,6 +130,54 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3
return outcome
}
var autoSaleRarityTiers = map[int32]bool{10: true, 20: true, 30: true, 40: true, 50: true}
// Rarity tiers (10..50) and ranks (1..5) are disjoint, so the delimited values
// are classified by range — independent of the client's map key or delimiter.
func parseAutoSaleRules(settings map[int32]store.AutoSaleSettingState) (raritySet, rankSet map[int32]bool) {
raritySet = map[int32]bool{}
rankSet = map[int32]bool{}
for _, s := range settings {
for _, n := range extractInts(s.PossessionAutoSaleItemValue) {
switch {
case autoSaleRarityTiers[n]:
raritySet[n] = true
case n >= 1 && n <= 5:
rankSet[n] = true
}
}
}
return raritySet, rankSet
}
func extractInts(s string) []int32 {
fields := strings.FieldsFunc(s, func(r rune) bool { return r < '0' || r > '9' })
out := make([]int32, 0, len(fields))
for _, f := range fields {
if v, err := strconv.Atoi(f); err == nil {
out = append(out, int32(v))
}
}
return out
}
func (h *QuestHandler) grantDropRewards(user *store.UserState, drops []RewardGrant, raritySet, rankSet map[int32]bool, nowMillis int64) {
for i := range drops {
d := drops[i]
if d.PossessionType == model.PossessionTypeParts || d.PossessionType == model.PossessionTypePartsEnhanced {
chosenId, sold := h.Granter.GrantOrSellPartsDrop(user, d.PossessionId, raritySet, rankSet, nowMillis)
if sold {
// Sold parts have no inventory row, so the popup needs the rolled
// variant id; kept parts read theirs from the parts table diff.
drops[i].PossessionId = chosenId
drops[i].IsAutoSale = true
}
continue
}
h.applyRewardPossession(user, d.PossessionType, d.PossessionId, d.Count, nowMillis)
}
}
func (h *QuestHandler) computeDropRewards(questDef masterdata.EntityMQuest, target campaign.QuestTarget, nowMillis int64) []RewardGrant {
var drops []RewardGrant
var dropRate campaign.DropRateMul