From b65c1c5fce001f3a5c881ea3c2667b0b0ca421e5 Mon Sep 17 00:00:00 2001 From: Ilya Groshev Date: Thu, 21 May 2026 14:15:11 +0300 Subject: [PATCH] Implement world-map entities --- server/internal/masterdata/gimmick.go | 549 ++++++++++++++++++++- server/internal/masterdata/hiddenstory.go | 103 ++++ server/internal/model/gimmick.go | 18 + server/internal/model/quest.go | 9 + server/internal/runtime/build.go | 2 +- server/internal/service/cageornament.go | 18 +- server/internal/service/gimmick.go | 142 +++++- server/internal/store/seed.go | 3 +- server/internal/userdata/changed_tables.go | 6 +- server/internal/userdata/proj_gimmick.go | 146 +++++- server/internal/userdata/proj_quest.go | 24 +- server/internal/userdata/proj_user.go | 28 +- 12 files changed, 998 insertions(+), 50 deletions(-) create mode 100644 server/internal/masterdata/hiddenstory.go create mode 100644 server/internal/model/gimmick.go diff --git a/server/internal/masterdata/gimmick.go b/server/internal/masterdata/gimmick.go index 1a320ee..74f0c9b 100644 --- a/server/internal/masterdata/gimmick.go +++ b/server/internal/masterdata/gimmick.go @@ -3,57 +3,463 @@ package masterdata import ( "fmt" "log" + "sort" + "sync" + "lunar-tear/server/internal/gametime" "lunar-tear/server/internal/model" "lunar-tear/server/internal/store" "lunar-tear/server/internal/utils" ) +// MaxUserGimmickRows is the server's per-table cap for gimmick projections and the +// in-memory user.Gimmick.Sequences map. The client hard-caps each of its +// IUserGimmick* tables at 1024 rows; we project up to this many with a small +// safety margin so we never overflow client buffers. Shared by InitSequenceSchedule +// (limits user.Gimmick.Sequences map size) and the IUserGimmick / Ornament / +// Sequence projections. +const MaxUserGimmickRows = 1000 + type gimmickScheduleEntry struct { ScheduleId int32 StartDatetime int64 EndDatetime int64 FirstSequenceId int32 RequiredQuestId int32 // 0 = always active + IsHidden bool // hidden-story or cage-memory gimmick: bypasses the quest gate + Rank int // trim priority — see gimmickTypeRank +} + +func readGimmickTable[T any](name, what string) ([]T, bool) { + rows, err := utils.ReadTable[T](name) + if err != nil { + log.Printf("[gimmick] %s unavailable, %s empty: %v", name, what, err) + return nil, false + } + return rows, true +} + +func gimmickTypeRank(t model.GimmickType) int { + switch t { + case model.GimmickTypeReport: // hidden missions / stories + return 0 + case model.GimmickTypeCageMemory: // lost archives + return 1 + case model.GimmickTypeCageTreasureHunt: // treasure + return 2 + case model.GimmickTypeBrokenObelisk, model.GimmickTypeFirstBrokenObelisk: + return 3 + case model.GimmickTypeIronGrill: + return 4 + case model.GimmickTypeRadioMessage: + return 5 + case model.GimmickTypeMapOnlyCageTreasureHunt, model.GimmickTypeMapOnlyHideObelisk: + return 6 + case model.GimmickTypeCageIntervalDropItem, model.GimmickTypeMapOnlyCageIntervalDrop: + return 7 // birds — bottom + } + return 8 +} + +type gimmickTypeTables struct { + byGimmick map[int32]model.GimmickType + bySequence map[int32]model.GimmickType +} + +var gimmickTypes = sync.OnceValue(loadGimmickTypes) + +func loadGimmickTypes() gimmickTypeTables { + empty := gimmickTypeTables{ + byGimmick: map[int32]model.GimmickType{}, + bySequence: map[int32]model.GimmickType{}, + } + + gimmicks, ok := readGimmickTable[EntityMGimmick]("m_gimmick", "type tables") + if !ok { + return empty + } + groups, ok := readGimmickTable[EntityMGimmickGroup]("m_gimmick_group", "type tables") + if !ok { + return empty + } + sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "type tables") + if !ok { + return empty + } + + byGimmick := make(map[int32]model.GimmickType, len(gimmicks)) + for _, g := range gimmicks { + byGimmick[g.GimmickId] = model.GimmickType(g.GimmickType) + } + typeByGroup := make(map[int32]model.GimmickType, len(groups)) + for _, grp := range groups { + if _, seen := typeByGroup[grp.GimmickGroupId]; seen { + continue + } + if t, ok := byGimmick[grp.GimmickId]; ok { + typeByGroup[grp.GimmickGroupId] = t + } + } + bySequence := make(map[int32]model.GimmickType, len(sequences)) + for _, seq := range sequences { + if t, ok := typeByGroup[seq.GimmickGroupId]; ok { + bySequence[seq.GimmickSequenceId] = t + } + } + return gimmickTypeTables{byGimmick: byGimmick, bySequence: bySequence} +} + +func gimmickSequenceTypes() map[int32]model.GimmickType { + return gimmickTypes().bySequence +} + +func LoadGimmickSequenceRanks() map[int32]int { + types := gimmickSequenceTypes() + out := make(map[int32]int, len(types)) + for sid, t := range types { + out[sid] = gimmickTypeRank(t) + } + return out +} + +type SequenceReward struct { + PossessionType int32 + PossessionId int32 + Count int32 } type GimmickCatalog struct { - schedules []gimmickScheduleEntry + schedules []gimmickScheduleEntry + hiddenSequences map[int32]bool // GimmickSequenceId -> report/cage-memory + sequenceRewards map[int32][]SequenceReward // GimmickSequenceId -> clear rewards + gimmickTypes map[int32]model.GimmickType + cageMemoryItems map[int32]int32 // CageMemory GimmickId -> ImportantItemId (type 4) + hiddenBirdRewards map[GimmickOrnamentRef]SequenceReward } -func LoadGimmickCatalog(resolver *ConditionResolver) (*GimmickCatalog, error) { +func LoadGimmickCatalog(resolver *ConditionResolver, cageOrnaments *CageOrnamentCatalog) (*GimmickCatalog, error) { rows, err := utils.ReadTable[EntityMGimmickSequenceSchedule]("m_gimmick_sequence_schedule") if err != nil { return nil, fmt.Errorf("load gimmick sequence schedule table: %w", err) } - entries := make([]gimmickScheduleEntry, 0, len(rows)) + seqTypes := gimmickSequenceTypes() + hiddenSeq := make(map[int32]bool, len(seqTypes)) + for sid, t := range seqTypes { + if t == model.GimmickTypeReport || t == model.GimmickTypeCageMemory { + hiddenSeq[sid] = true + } + } + + // Pick rule: prefer schedules with EndDatetime in the future (so the client's + // in-cage filter doesn't drop them), then by lowest StartDatetime (longest + // historical validity, tends to carry the simplest RequiredQuestId), tie-broken + // by lowest ScheduleId for determinism. The future-end preference matters for + // "Fickle Black Birds" (type 1) where real expiry dates vary; other types have + // EndDatetime = 9999-03-31 so the preference is a no-op. + now := gametime.NowMillis() + bestBySeq := make(map[int32]gimmickScheduleEntry, len(rows)) for _, r := range rows { entry := gimmickScheduleEntry{ ScheduleId: r.GimmickSequenceScheduleId, StartDatetime: r.StartDatetime, EndDatetime: r.EndDatetime, FirstSequenceId: r.FirstGimmickSequenceId, + IsHidden: hiddenSeq[r.FirstGimmickSequenceId], + Rank: gimmickTypeRank(seqTypes[r.FirstGimmickSequenceId]), } if r.ReleaseEvaluateConditionId != 0 { if qid, ok := resolver.RequiredQuestId(r.ReleaseEvaluateConditionId); ok { entry.RequiredQuestId = qid } } - entries = append(entries, entry) + if existing, ok := bestBySeq[entry.FirstSequenceId]; ok { + existingFuture := existing.EndDatetime > now + entryFuture := entry.EndDatetime > now + if existingFuture != entryFuture { + // Future-end schedule wins over expired one. + if existingFuture { + continue + } + } else if existing.StartDatetime < entry.StartDatetime || + (existing.StartDatetime == entry.StartDatetime && existing.ScheduleId <= entry.ScheduleId) { + continue + } + } + bestBySeq[entry.FirstSequenceId] = entry } - log.Printf("gimmick catalog loaded: %d schedules", len(entries)) - return &GimmickCatalog{schedules: entries}, nil + entries := make([]gimmickScheduleEntry, 0, len(bestBySeq)) + hiddenCount := 0 + for _, entry := range bestBySeq { + if entry.IsHidden { + hiddenCount++ + } + entries = append(entries, entry) + } + dedupedCount := len(rows) - len(entries) + + // Sort by (Rank, ScheduleId) so ActiveScheduleKeys returns the priority order + // directly: treasure/lost-archives/hidden-missions first, birds last. This is + // what lets InitSequenceSchedule's 1000-row cap trim from the bottom. + sort.Slice(entries, func(i, j int) bool { + if entries[i].Rank != entries[j].Rank { + return entries[i].Rank < entries[j].Rank + } + return entries[i].ScheduleId < entries[j].ScheduleId + }) + + sequenceRewards := loadGimmickSequenceRewards() + cageMemoryItems := loadCageMemoryImportantItems(gimmickTypes().byGimmick) + hiddenBirdRewards := loadHiddenBirdRewards(cageOrnaments) + + log.Printf("gimmick catalog loaded: %d schedules (%d hidden-content, %d duplicates dropped), %d reward sequences, %d cage-memory items, %d hidden-bird rewards", + len(entries), hiddenCount, dedupedCount, len(sequenceRewards), len(cageMemoryItems), len(hiddenBirdRewards)) + return &GimmickCatalog{ + schedules: entries, + hiddenSequences: hiddenSeq, + sequenceRewards: sequenceRewards, + gimmickTypes: gimmickTypes().byGimmick, + cageMemoryItems: cageMemoryItems, + hiddenBirdRewards: hiddenBirdRewards, + }, nil +} + +// HiddenBirdReward returns the per-tap reward for a MAP_ONLY_CAGE_TREASURE_HUNT +// ("Hidden Black Birds", type 7) ornament. Returns false if there's no mapping +// (e.g. the ornament view has no corresponding cage-ornament-reward entry). +func (c *GimmickCatalog) HiddenBirdReward(gimmickId, ornamentIndex int32) (SequenceReward, bool) { + r, ok := c.hiddenBirdRewards[GimmickOrnamentRef{GimmickId: gimmickId, OrnamentIndex: ornamentIndex}] + return r, ok +} + +// loadHiddenBirdRewards resolves (GimmickId, OrnamentIndex) -> CageOrnamentReward for +// every type-7 ("Hidden Black Birds") gimmick. The mapping is structural: +// +// m_gimmick (GimmickType == 7) -> GimmickOrnamentGroupId +// m_gimmick_ornament (matching group) -> GimmickOrnamentViewId +// m_cage_ornament (CageOrnamentId == ViewId) -> CageOrnamentRewardId +// m_cage_ornament_reward (matching id) -> PossessionType / PossessionId / Count +// +// 110 of 114 type-7 ornaments have a matching m_cage_ornament row in the current +// data; the rest log a warning and are silently skipped so the player just gets +// no reward on those (no crash). +func loadHiddenBirdRewards(cageOrnaments *CageOrnamentCatalog) map[GimmickOrnamentRef]SequenceReward { + empty := map[GimmickOrnamentRef]SequenceReward{} + if cageOrnaments == nil { + return empty + } + + gimmicks, ok := readGimmickTable[EntityMGimmick]("m_gimmick", "hidden-bird rewards") + if !ok { + return empty + } + ornaments, ok := readGimmickTable[EntityMGimmickOrnament]("m_gimmick_ornament", "hidden-bird rewards") + if !ok { + return empty + } + + gimmicksByGroup := make(map[int32][]int32) + for _, g := range gimmicks { + if model.GimmickType(g.GimmickType) == model.GimmickTypeMapOnlyCageTreasureHunt { + gimmicksByGroup[g.GimmickOrnamentGroupId] = append(gimmicksByGroup[g.GimmickOrnamentGroupId], g.GimmickId) + } + } + + out := make(map[GimmickOrnamentRef]SequenceReward) + missing := 0 + for _, o := range ornaments { + gids, ok := gimmicksByGroup[o.GimmickOrnamentGroupId] + if !ok { + continue + } + reward, ok := cageOrnaments.LookupReward(o.GimmickOrnamentViewId) + if !ok { + missing++ + continue + } + entry := SequenceReward{ + PossessionType: reward.PossessionType, + PossessionId: reward.PossessionId, + Count: reward.Count, + } + for _, gid := range gids { + out[GimmickOrnamentRef{GimmickId: gid, OrnamentIndex: o.GimmickOrnamentIndex}] = entry + } + } + if missing > 0 { + log.Printf("[gimmick] %d hidden-bird ornaments had no m_cage_ornament_reward row", missing) + } + return out +} + +func (c *GimmickCatalog) GimmickType(gimmickId int32) model.GimmickType { + return c.gimmickTypes[gimmickId] +} + +// CageMemoryImportantItem returns the ImportantItemId (type 4) that the library uses +// to mark a tapped cage memory as collected, given the world-gimmick id. The mapping +// is derived from m_gimmick_additional_asset texture suffixes — see +// loadCageMemoryImportantItems. +func (c *GimmickCatalog) CageMemoryImportantItem(gimmickId int32) (int32, bool) { + id, ok := c.cageMemoryItems[gimmickId] + return id, ok +} + +// importantItemTypeCageMemory mirrors EntityMImportantItem.ImportantItemType==4 — the +// CageMemory entry that the library's HasCageMemory check resolves to. +const importantItemTypeCageMemory int32 = 4 + +func loadCageMemoryImportantItems(typeByGimmick map[int32]model.GimmickType) map[int32]int32 { + empty := map[int32]int32{} + + ornaments, ok := readGimmickTable[EntityMGimmickOrnament]("m_gimmick_ornament", "cage-memory items") + if !ok { + return empty + } + chapters, ok := readGimmickTable[EntityMMainQuestChapter]("m_main_quest_chapter", "cage-memory items") + if !ok { + return empty + } + routes, ok := readGimmickTable[EntityMMainQuestRoute]("m_main_quest_route", "cage-memory items") + if !ok { + return empty + } + cageMemories, ok := readGimmickTable[EntityMCageMemory]("m_cage_memory", "cage-memory items") + if !ok { + return empty + } + items, ok := readGimmickTable[EntityMImportantItem]("m_important_item", "cage-memory items") + if !ok { + return empty + } + + chapterByOrnamentGroup := make(map[int32]int32, len(ornaments)) + for _, o := range ornaments { + if _, seen := chapterByOrnamentGroup[o.GimmickOrnamentGroupId]; seen { + continue + } + chapterByOrnamentGroup[o.GimmickOrnamentGroupId] = o.ChapterId + } + routeByChapter := make(map[int32]int32, len(chapters)) + for _, c := range chapters { + routeByChapter[c.MainQuestChapterId] = c.MainQuestRouteId + } + seasonByRoute := make(map[int32]int32, len(routes)) + for _, r := range routes { + seasonByRoute[r.MainQuestRouteId] = r.MainQuestSeasonId + } + cmsBySeason := make(map[int32][]int32) + for _, c := range cageMemories { + cmsBySeason[c.MainQuestSeasonId] = append(cmsBySeason[c.MainQuestSeasonId], c.CageMemoryId) + } + for s := range cmsBySeason { + sort.Slice(cmsBySeason[s], func(i, j int) bool { return cmsBySeason[s][i] < cmsBySeason[s][j] }) + } + itemByCageMemory := make(map[int32]int32) + for _, it := range items { + if it.ImportantItemType == importantItemTypeCageMemory && it.CageMemoryId != 0 { + itemByCageMemory[it.CageMemoryId] = it.ImportantItemId + } + } + + gimmicksByRoute := make(map[int32][]int32) + for gid, t := range typeByGimmick { + if t != model.GimmickTypeCageMemory { + continue + } + chapter, ok := chapterByOrnamentGroup[gid] + if !ok { + log.Printf("[gimmick] cage-memory %d has no ornament row, skipping mapping", gid) + continue + } + route, ok := routeByChapter[chapter] + if !ok { + log.Printf("[gimmick] cage-memory %d chapter %d has no route, skipping mapping", gid, chapter) + continue + } + gimmicksByRoute[route] = append(gimmicksByRoute[route], gid) + } + for r := range gimmicksByRoute { + sort.Slice(gimmicksByRoute[r], func(i, j int) bool { return gimmicksByRoute[r][i] < gimmicksByRoute[r][j] }) + } + + out := make(map[int32]int32) + for route, gids := range gimmicksByRoute { + season, ok := seasonByRoute[route] + if !ok { + log.Printf("[gimmick] route %d has no season, skipping %d cage-memory gimmicks", route, len(gids)) + continue + } + seasonCms := cmsBySeason[season] + for i, gid := range gids { + if i >= len(seasonCms) { + log.Printf("[gimmick] route %d (season %d) has %d cage-memory gimmicks but only %d cage memories; gimmick %d skipped", + route, season, len(gids), len(seasonCms), gid) + continue + } + cageMemoryId := seasonCms[i] + itemId, ok := itemByCageMemory[cageMemoryId] + if !ok { + log.Printf("[gimmick] cage memory %d (gimmick %d) has no m_important_item row (type 4), skipping", + cageMemoryId, gid) + continue + } + out[gid] = itemId + } + } + return out +} + +func loadGimmickSequenceRewards() map[int32][]SequenceReward { + empty := map[int32][]SequenceReward{} + + sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "sequence rewards") + if !ok { + return empty + } + rewardGroups, ok := readGimmickTable[EntityMGimmickSequenceRewardGroup]("m_gimmick_sequence_reward_group", "sequence rewards") + if !ok { + return empty + } + + rewardsByGroup := make(map[int32][]SequenceReward) + for _, rg := range rewardGroups { + if rg.PossessionType == 0 || rg.PossessionId == 0 { + continue + } + rewardsByGroup[rg.GimmickSequenceRewardGroupId] = append( + rewardsByGroup[rg.GimmickSequenceRewardGroupId], SequenceReward{ + PossessionType: rg.PossessionType, + PossessionId: rg.PossessionId, + Count: rg.Count, + }) + } + + rewardsBySequence := make(map[int32][]SequenceReward, len(sequences)) + for _, seq := range sequences { + if rewards := rewardsByGroup[seq.GimmickSequenceRewardGroupId]; len(rewards) > 0 { + rewardsBySequence[seq.GimmickSequenceId] = rewards + } + } + return rewardsBySequence +} + +func (c *GimmickCatalog) IsHiddenSequence(sequenceId int32) bool { + return c.hiddenSequences[sequenceId] +} + +func (c *GimmickCatalog) SequenceRewards(sequenceId int32) []SequenceReward { + return c.sequenceRewards[sequenceId] } func (c *GimmickCatalog) ActiveScheduleKeys(user store.UserState, nowMillis int64) []store.GimmickSequenceKey { - var keys []store.GimmickSequenceKey + keys := make([]store.GimmickSequenceKey, 0, len(c.schedules)) for _, s := range c.schedules { - if nowMillis < s.StartDatetime || nowMillis > s.EndDatetime { - continue + if nowMillis < s.StartDatetime { + continue // future schedules still skipped } - if s.RequiredQuestId != 0 { + if !s.IsHidden && s.RequiredQuestId != 0 { q, ok := user.Quests[s.RequiredQuestId] if !ok || q.QuestStateType != model.UserQuestStateTypeCleared { continue @@ -66,3 +472,126 @@ func (c *GimmickCatalog) ActiveScheduleKeys(user store.UserState, nowMillis int6 } return keys } + +type GimmickOrnamentRef struct { + GimmickId int32 + OrnamentIndex int32 +} + +func LoadGimmickOrnamentRefs() map[int32][]GimmickOrnamentRef { + empty := map[int32][]GimmickOrnamentRef{} + + sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "ornament refs") + if !ok { + return empty + } + groups, ok := readGimmickTable[EntityMGimmickGroup]("m_gimmick_group", "ornament refs") + if !ok { + return empty + } + gimmicks, ok := readGimmickTable[EntityMGimmick]("m_gimmick", "ornament refs") + if !ok { + return empty + } + ornaments, ok := readGimmickTable[EntityMGimmickOrnament]("m_gimmick_ornament", "ornament refs") + if !ok { + return empty + } + + indicesByOrnamentGroup := make(map[int32][]int32) + for _, o := range ornaments { + indicesByOrnamentGroup[o.GimmickOrnamentGroupId] = append( + indicesByOrnamentGroup[o.GimmickOrnamentGroupId], o.GimmickOrnamentIndex) + } + ornamentGroupByGimmick := make(map[int32]int32, len(gimmicks)) + for _, g := range gimmicks { + ornamentGroupByGimmick[g.GimmickId] = g.GimmickOrnamentGroupId + } + gimmicksByGroup := make(map[int32][]int32) + for _, grp := range groups { + gimmicksByGroup[grp.GimmickGroupId] = append(gimmicksByGroup[grp.GimmickGroupId], grp.GimmickId) + } + + refsBySequence := make(map[int32][]GimmickOrnamentRef, len(sequences)) + for _, seq := range sequences { + var refs []GimmickOrnamentRef + for _, gimmickId := range gimmicksByGroup[seq.GimmickGroupId] { + for _, ornamentIndex := range indicesByOrnamentGroup[ornamentGroupByGimmick[gimmickId]] { + refs = append(refs, GimmickOrnamentRef{GimmickId: gimmickId, OrnamentIndex: ornamentIndex}) + } + } + if len(refs) > 0 { + refsBySequence[seq.GimmickSequenceId] = refs + } + } + log.Printf("gimmick ornament refs loaded: %d sequences", len(refsBySequence)) + return refsBySequence +} + +func LoadHiddenGimmickSequenceIDs() map[int32]bool { + types := gimmickSequenceTypes() + out := make(map[int32]bool, len(types)) + for sid, t := range types { + if t == model.GimmickTypeReport || t == model.GimmickTypeCageMemory || t == model.GimmickTypeMapOnlyCageTreasureHunt { + out[sid] = true + } + } + return out +} + +func LoadBirdGimmickIDs() map[int32]bool { + byGimmick := gimmickTypes().byGimmick + out := make(map[int32]bool, len(byGimmick)) + for gid, t := range byGimmick { + if t == model.GimmickTypeCageIntervalDropItem || t == model.GimmickTypeMapOnlyCageIntervalDrop { + out[gid] = true + } + } + return out +} + +func LoadGimmickSequenceChains() map[int32][]int32 { + empty := map[int32][]int32{} + + sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "sequence chains") + if !ok { + return empty + } + groups, ok := readGimmickTable[EntityMGimmickSequenceGroup]("m_gimmick_sequence_group", "sequence chains") + if !ok { + return empty + } + + membersByGroup := make(map[int32][]int32) + for _, g := range groups { + membersByGroup[g.GimmickSequenceGroupId] = append(membersByGroup[g.GimmickSequenceGroupId], g.GimmickSequenceId) + } + nextGroupBySequence := make(map[int32]int32, len(sequences)) + for _, seq := range sequences { + nextGroupBySequence[seq.GimmickSequenceId] = seq.NextGimmickSequenceGroupId + } + + chains := make(map[int32][]int32, len(sequences)) + for _, seq := range sequences { + start := seq.GimmickSequenceId + seen := map[int32]bool{start: true} + chain := []int32{start} + for queue := []int32{start}; len(queue) > 0; { + cur := queue[0] + queue = queue[1:] + nextGroup := nextGroupBySequence[cur] + if nextGroup == 0 { + continue + } + for _, member := range membersByGroup[nextGroup] { + if !seen[member] { + seen[member] = true + chain = append(chain, member) + queue = append(queue, member) + } + } + } + chains[start] = chain + } + return chains +} diff --git a/server/internal/masterdata/hiddenstory.go b/server/internal/masterdata/hiddenstory.go new file mode 100644 index 0000000..5d2ffe1 --- /dev/null +++ b/server/internal/masterdata/hiddenstory.go @@ -0,0 +1,103 @@ +package masterdata + +import ( + "log" + + "lunar-tear/server/internal/model" + "lunar-tear/server/internal/store" + "lunar-tear/server/internal/utils" +) + +type HiddenStoryRequirements struct { + MissionIds []int32 + QuestMissions []store.QuestMissionKey +} + +func LoadHiddenStoryRequirements() HiddenStoryRequirements { + var empty HiddenStoryRequirements + + gimmicks, err := utils.ReadTable[EntityMGimmick]("m_gimmick") + if err != nil { + log.Printf("[hiddenstory] m_gimmick unavailable: %v", err) + return empty + } + conditions, err := utils.ReadTable[EntityMEvaluateCondition]("m_evaluate_condition") + if err != nil { + log.Printf("[hiddenstory] m_evaluate_condition unavailable: %v", err) + return empty + } + valueGroups, err := utils.ReadTable[EntityMEvaluateConditionValueGroup]("m_evaluate_condition_value_group") + if err != nil { + log.Printf("[hiddenstory] m_evaluate_condition_value_group unavailable: %v", err) + return empty + } + + condById := make(map[int32]EntityMEvaluateCondition, len(conditions)) + for _, c := range conditions { + condById[c.EvaluateConditionId] = c + } + valuesByGroup := make(map[int32]map[int32]int64) + for _, vg := range valueGroups { + g := valuesByGroup[vg.EvaluateConditionValueGroupId] + if g == nil { + g = make(map[int32]int64) + valuesByGroup[vg.EvaluateConditionValueGroupId] = g + } + g[vg.GroupIndex] = vg.Value + } + + missionSet := make(map[int32]struct{}) + questMissionSet := make(map[store.QuestMissionKey]struct{}) + seen := make(map[int32]bool) + + var resolve func(conditionId int32, depth int) + resolve = func(conditionId int32, depth int) { + if conditionId == 0 || depth > 16 || seen[conditionId] { + return + } + seen[conditionId] = true + c, ok := condById[conditionId] + if !ok { + return + } + group := valuesByGroup[c.EvaluateConditionValueGroupId] + switch model.EvaluateConditionFunctionType(c.EvaluateConditionFunctionType) { + case model.EvaluateConditionFunctionTypeRecursion: + // Value-group entries are sub-condition ids; satisfying all leaves makes + // both AND and OR recursion conditions evaluate true. + for _, sub := range group { + resolve(int32(sub), depth+1) + } + case model.EvaluateConditionFunctionTypeMissionClear: + if v, ok := group[defaultGroupIndex]; ok { + missionSet[int32(v)] = struct{}{} + } + case model.EvaluateConditionFunctionTypeQuestMissionClear: + questId, ok1 := group[1] + questMissionId, ok2 := group[2] + if ok1 && ok2 { + questMissionSet[store.QuestMissionKey{ + QuestId: int32(questId), + QuestMissionId: int32(questMissionId), + }] = struct{}{} + } + } + } + + for _, g := range gimmicks { + switch model.GimmickType(g.GimmickType) { + case model.GimmickTypeReport, model.GimmickTypeCageMemory: + resolve(g.ClearEvaluateConditionId, 0) + } + } + + req := HiddenStoryRequirements{} + for id := range missionSet { + req.MissionIds = append(req.MissionIds, id) + } + for key := range questMissionSet { + req.QuestMissions = append(req.QuestMissions, key) + } + log.Printf("hidden-story requirements: %d missions, %d quest-missions", len(req.MissionIds), len(req.QuestMissions)) + return req +} diff --git a/server/internal/model/gimmick.go b/server/internal/model/gimmick.go new file mode 100644 index 0000000..f1317a2 --- /dev/null +++ b/server/internal/model/gimmick.go @@ -0,0 +1,18 @@ +package model + +type GimmickType int32 + +const ( + GimmickTypeUnknown GimmickType = 0 + GimmickTypeCageTreasureHunt GimmickType = 1 // "Fickle Black Birds" — in-cage flick-and-tap birds + GimmickTypeCageIntervalDropItem GimmickType = 2 // "Lost Items" — in-cage 3-dot drops that respawn on interval + GimmickTypeBrokenObelisk GimmickType = 3 // "Broken Scarecrow" (per tip text); zero rows in m_gimmick, unused + GimmickTypeIronGrill GimmickType = 4 // unused (zero rows in m_gimmick), in-game name unknown + GimmickTypeRadioMessage GimmickType = 5 // unused (zero rows in m_gimmick); client has GimmickRadioMessage class but no data + GimmickTypeFirstBrokenObelisk GimmickType = 6 // variant of Broken Scarecrow; zero rows in m_gimmick, unused + GimmickTypeMapOnlyCageTreasureHunt GimmickType = 7 // "Hidden Black Birds" — world-map birds; per-tap reward from m_cage_ornament_reward + GimmickTypeMapOnlyCageIntervalDrop GimmickType = 8 // map-side variant of Lost Items + GimmickTypeReport GimmickType = 9 // "Hidden Stories" — hidden mission markers + GimmickTypeCageMemory GimmickType = 10 // "Lost Archives" — collectible library entries (one-shot ImportantItem type-4) + GimmickTypeMapOnlyHideObelisk GimmickType = 11 // "Stray Scarecrow" — world-map scarecrows (not yet implemented) +) diff --git a/server/internal/model/quest.go b/server/internal/model/quest.go index af1ecc3..fc83394 100644 --- a/server/internal/model/quest.go +++ b/server/internal/model/quest.go @@ -43,6 +43,15 @@ const ( QuestResultTypeFullResult QuestResultType = 3 ) +type MissionProgressStatusType int32 + +const ( + MissionProgressStatusTypeUnknown MissionProgressStatusType = 0 + MissionProgressStatusTypeInProgress MissionProgressStatusType = 1 + MissionProgressStatusTypeClear MissionProgressStatusType = 2 + MissionProgressStatusTypeRewardReceived MissionProgressStatusType = 9 +) + type QuestSceneType int32 const ( diff --git a/server/internal/runtime/build.go b/server/internal/runtime/build.go index a425820..678cd7e 100644 --- a/server/internal/runtime/build.go +++ b/server/internal/runtime/build.go @@ -114,7 +114,7 @@ func buildCatalogs() (*Catalogs, error) { } log.Printf("explore catalog loaded: %d explores, %d grade assets", len(exploreCatalog.Explores), len(exploreCatalog.GradeAssets)) - gimmickCatalog, err := masterdata.LoadGimmickCatalog(conditionResolver) + gimmickCatalog, err := masterdata.LoadGimmickCatalog(conditionResolver, cageOrnamentCatalog) if err != nil { return nil, fmt.Errorf("load gimmick catalog: %w", err) } diff --git a/server/internal/service/cageornament.go b/server/internal/service/cageornament.go index 1801ea1..bea0b67 100644 --- a/server/internal/service/cageornament.go +++ b/server/internal/service/cageornament.go @@ -27,10 +27,6 @@ func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.R cat := s.holder.Get() reward, ok := cat.CageOrnament.LookupReward(req.CageOrnamentId) - if !ok { - log.Fatalf("[CageOrnamentService] ReceiveReward: no reward for cageOrnamentId=%d", req.CageOrnamentId) - } - granter := cat.QuestHandler.Granter userId := CurrentUserId(ctx, s.users, s.sessions) nowMillis := gametime.NowMillis() @@ -40,9 +36,21 @@ func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.R AcquisitionDatetime: nowMillis, LatestVersion: nowMillis, } - granter.GrantFull(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis) + if ok { + cat.QuestHandler.Granter.GrantFull(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis) + } }) + if !ok { + // "Fickle Black Birds" (type-1 gimmicks) tap into this RPC with CageOrnamentIds + // not present in m_cage_ornament_reward (their GimmickOrnamentViewIds are 101/103, + // not the 1002xxx-style ids the table uses). Record the access and return an empty + // reward so the client doesn't hang and the server doesn't crash. + log.Printf("[CageOrnamentService] ReceiveReward: no reward mapping for cageOrnamentId=%d, returning empty", + req.CageOrnamentId) + return &pb.ReceiveRewardResponse{}, nil + } + return &pb.ReceiveRewardResponse{ CageOrnamentReward: []*pb.CageOrnamentReward{ { diff --git a/server/internal/service/gimmick.go b/server/internal/service/gimmick.go index 65b6639..92f7bdc 100644 --- a/server/internal/service/gimmick.go +++ b/server/internal/service/gimmick.go @@ -6,6 +6,8 @@ import ( pb "lunar-tear/server/gen/proto" "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" @@ -43,6 +45,10 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p log.Printf("[GimmickService] UpdateGimmickProgress: scheduleId=%d sequenceId=%d gimmickId=%d ornamentIndex=%d progressValueBit=%d flowType=%d", req.GimmickSequenceScheduleId, req.GimmickSequenceId, req.GimmickId, req.GimmickOrnamentIndex, req.ProgressValueBit, req.FlowType) userId := CurrentUserId(ctx, s.users, s.sessions) + cat := s.holder.Get() + + var ornamentRewards []*pb.GimmickReward + var sequenceCleared bool s.users.UpdateUser(userId, func(user *store.UserState) { nowMillis := gametime.NowMillis() progressKey := store.GimmickKey{ @@ -53,7 +59,6 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p progress := user.Gimmick.Progress[progressKey] progress.Key = progressKey progress.StartDatetime = nowMillis - user.Gimmick.Progress[progressKey] = progress ornamentKey := store.GimmickOrnamentKey{ GimmickSequenceScheduleId: req.GimmickSequenceScheduleId, @@ -66,28 +71,151 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p ornament.ProgressValueBit = req.ProgressValueBit ornament.BaseDatetime = nowMillis user.Gimmick.OrnamentProgress[ornamentKey] = ornament + + // Per-type branches: + // * Report (type 9, "Hidden Stories") — mark gimmick + sequence + // cleared, grant SequenceRewards (ImportantItem type 3, library reads it). + // * MapOnlyCageTreasureHunt (type 7, "Hidden Black Birds") — same as Report + // but the per-tap reward also comes back from m_cage_ornament_reward via + // GimmickOrnamentViewId. + // * CageMemory (type 10, "Lost Archives") — resolve an ImportantItem + // (type 4) from the gimmick's monitor texture and grant it. IsGimmickCleared + // stays false (matches original userdata; only ornament progress flips). + // * CageTreasureHunt / CageIntervalDropItem* — stub per-tap material so + // the client's reward popup fires; real reward source still unmapped. + switch cat.Gimmick.GimmickType(req.GimmickId) { + case model.GimmickTypeReport: + progress.IsGimmickCleared = true + sequenceCleared = markSequenceClearedOnce(user, cat, req.GimmickSequenceScheduleId, req.GimmickSequenceId, nowMillis) + + case model.GimmickTypeMapOnlyCageTreasureHunt: + r, ok := cat.Gimmick.HiddenBirdReward(req.GimmickId, req.GimmickOrnamentIndex) + if !ok { + log.Printf("[GimmickService] UpdateGimmickProgress: hidden-bird %d ornament %d has no reward mapping, skipping", + req.GimmickId, req.GimmickOrnamentIndex) + break + } + cat.QuestHandler.Granter.GrantFull(user, model.PossessionType(r.PossessionType), r.PossessionId, r.Count, nowMillis) + ornamentRewards = append(ornamentRewards, &pb.GimmickReward{ + PossessionType: r.PossessionType, + PossessionId: r.PossessionId, + Count: r.Count, + }) + progress.IsGimmickCleared = true + sequenceCleared = markSequenceClearedOnce(user, cat, req.GimmickSequenceScheduleId, req.GimmickSequenceId, nowMillis) + + case model.GimmickTypeCageMemory: + itemId, ok := cat.Gimmick.CageMemoryImportantItem(req.GimmickId) + if !ok { + log.Printf("[GimmickService] UpdateGimmickProgress: cage memory %d has no important-item mapping, skipping grant", + req.GimmickId) + break + } + if _, owned := user.ImportantItems[itemId]; owned { + break + } + cat.QuestHandler.Granter.GrantFull(user, model.PossessionTypeImportantItem, itemId, 1, nowMillis) + ornamentRewards = append(ornamentRewards, &pb.GimmickReward{ + PossessionType: int32(model.PossessionTypeImportantItem), + PossessionId: itemId, + Count: 1, + }) + + case model.GimmickTypeCageTreasureHunt, + model.GimmickTypeCageIntervalDropItem, + model.GimmickTypeMapOnlyCageIntervalDrop: + // Per-tap drops with no per-gimmick reward in master data: + // * type 1 — "Fickle Black Birds" in the cage + // * type 2 — "Lost Items" in the cage + // * type 8 — Lost Items (map variant) + // Stub: grant 1 of Material 100004 (the most-common reward across + // m_cage_ornament_reward — 15 occurrences — likely a low-tier shard) per + // tap so the client's reward-popup path fires and the player accumulates + // something. Replace once a real per-gimmick mapping surfaces. + const stubMaterialId = int32(100004) + const stubMaterialCount = int32(1) + cat.QuestHandler.Granter.GrantFull(user, model.PossessionTypeMaterial, stubMaterialId, stubMaterialCount, nowMillis) + ornamentRewards = append(ornamentRewards, &pb.GimmickReward{ + PossessionType: int32(model.PossessionTypeMaterial), + PossessionId: stubMaterialId, + Count: stubMaterialCount, + }) + } + user.Gimmick.Progress[progressKey] = progress }) + + var clearReward []*pb.GimmickReward + if sequenceCleared { + for _, r := range cat.Gimmick.SequenceRewards(req.GimmickSequenceId) { + clearReward = append(clearReward, &pb.GimmickReward{ + PossessionType: r.PossessionType, + PossessionId: r.PossessionId, + Count: r.Count, + }) + } + } return &pb.UpdateGimmickProgressResponse{ - GimmickOrnamentReward: []*pb.GimmickReward{}, - IsSequenceCleared: false, - GimmickSequenceClearReward: []*pb.GimmickReward{}, + GimmickOrnamentReward: ornamentRewards, + IsSequenceCleared: sequenceCleared, + GimmickSequenceClearReward: clearReward, }, nil } +func markSequenceClearedOnce(user *store.UserState, cat *runtime.Catalogs, scheduleId, sequenceId int32, nowMillis int64) bool { + seqKey := store.GimmickSequenceKey{ + GimmickSequenceScheduleId: scheduleId, + GimmickSequenceId: sequenceId, + } + sequence := user.Gimmick.Sequences[seqKey] + sequence.Key = seqKey + defer func() { user.Gimmick.Sequences[seqKey] = sequence }() + + if sequence.IsGimmickSequenceCleared { + return false + } + sequence.IsGimmickSequenceCleared = true + sequence.ClearDatetime = nowMillis + for _, r := range cat.Gimmick.SequenceRewards(sequenceId) { + cat.QuestHandler.Granter.GrantFull(user, model.PossessionType(r.PossessionType), r.PossessionId, r.Count, nowMillis) + } + return true +} + func (s *GimmickServiceServer) InitSequenceSchedule(ctx context.Context, _ *emptypb.Empty) (*pb.InitSequenceScheduleResponse, error) { log.Printf("[GimmickService] InitSequenceSchedule") userId := CurrentUserId(ctx, s.users, s.sessions) now := gametime.NowMillis() s.users.UpdateUser(userId, func(user *store.UserState) { + eligible := s.holder.Get().Gimmick.ActiveScheduleKeys(*user, now) + eligibleSet := make(map[store.GimmickSequenceKey]struct{}, len(eligible)) + for _, key := range eligible { + eligibleSet[key] = struct{}{} + } + pruned := 0 + for key, entry := range user.Gimmick.Sequences { + if _, ok := eligibleSet[key]; ok { + continue + } + if entry.IsGimmickSequenceCleared { + continue + } + delete(user.Gimmick.Sequences, key) + pruned++ + } + added := 0 - for _, key := range s.holder.Get().Gimmick.ActiveScheduleKeys(*user, now) { + for _, key := range eligible { + if len(user.Gimmick.Sequences) >= masterdata.MaxUserGimmickRows { + break + } if _, exists := user.Gimmick.Sequences[key]; !exists { user.Gimmick.Sequences[key] = store.GimmickSequenceState{Key: key} added++ } } - if added > 0 { - log.Printf("[GimmickService] InitSequenceSchedule: added %d sequences (total %d)", added, len(user.Gimmick.Sequences)) + if pruned > 0 || added > 0 { + log.Printf("[GimmickService] InitSequenceSchedule: pruned %d stale, added %d sequences (total %d, eligible %d, cap %d)", + pruned, added, len(user.Gimmick.Sequences), len(eligible), masterdata.MaxUserGimmickRows) } }) return &pb.InitSequenceScheduleResponse{}, nil diff --git a/server/internal/store/seed.go b/server/internal/store/seed.go index 8d741b3..6527019 100644 --- a/server/internal/store/seed.go +++ b/server/internal/store/seed.go @@ -8,7 +8,6 @@ const ( starterMissionId = int32(1) starterMainQuestRouteId = int32(1) starterMainQuestSeasonId = int32(1) - missionInProgress = int32(1) defaultBirthYear = int32(2000) defaultBirthMonth = int32(1) @@ -114,7 +113,7 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl starterMissionId: { MissionId: starterMissionId, StartDatetime: nowMillis, - MissionProgressStatusType: missionInProgress, + MissionProgressStatusType: int32(model.MissionProgressStatusTypeInProgress), }, }, Gimmick: GimmickState{ diff --git a/server/internal/userdata/changed_tables.go b/server/internal/userdata/changed_tables.go index 2df6109..0e89dff 100644 --- a/server/internal/userdata/changed_tables.go +++ b/server/internal/userdata/changed_tables.go @@ -287,10 +287,12 @@ func ChangedTables(before, after *store.UserState) []string { } if !gimmickStateEqual(before.Gimmick, after.Gimmick) { - if !mapsEqualStruct(before.Gimmick.Progress, after.Gimmick.Progress) { + if !mapsEqualStruct(before.Gimmick.Progress, after.Gimmick.Progress) || + !mapsEqualStruct(before.Gimmick.Sequences, after.Gimmick.Sequences) { add("IUserGimmick") } - if !mapsEqualStruct(before.Gimmick.OrnamentProgress, after.Gimmick.OrnamentProgress) { + if !mapsEqualStruct(before.Gimmick.OrnamentProgress, after.Gimmick.OrnamentProgress) || + !mapsEqualStruct(before.Gimmick.Sequences, after.Gimmick.Sequences) { add("IUserGimmickOrnamentProgress") } if !mapsEqualStruct(before.Gimmick.Sequences, after.Gimmick.Sequences) { diff --git a/server/internal/userdata/proj_gimmick.go b/server/internal/userdata/proj_gimmick.go index 5e76c21..19d7db7 100644 --- a/server/internal/userdata/proj_gimmick.go +++ b/server/internal/userdata/proj_gimmick.go @@ -2,11 +2,21 @@ package userdata import ( "sort" + "sync" + "lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/store" "lunar-tear/server/internal/utils" ) +var gimmickOrnamentRefs = sync.OnceValue(masterdata.LoadGimmickOrnamentRefs) +var gimmickSequenceChains = sync.OnceValue(masterdata.LoadGimmickSequenceChains) +var hiddenSequenceSet = sync.OnceValue(masterdata.LoadHiddenGimmickSequenceIDs) +var gimmickSequenceRanks = sync.OnceValue(masterdata.LoadGimmickSequenceRanks) +var birdGimmicks = sync.OnceValue(masterdata.LoadBirdGimmickIDs) + +const birdDefaultBaseDatetime int64 = 1577836800000 // 2020-01-01 00:00:00 UTC in ms + func init() { register("IUserGimmick", func(user store.UserState) string { s, _ := utils.EncodeJSONMaps(sortedGimmickRecords(user)...) @@ -26,9 +36,65 @@ func init() { }) } +func projectActiveChainOrnaments( + user store.UserState, + addKey func(seqKey store.GimmickSequenceKey, seqId int32, ref masterdata.GimmickOrnamentRef), + sizeFn func() int, + cap int, +) { + refs := gimmickOrnamentRefs() + chains := gimmickSequenceChains() + hiddenSeq := hiddenSequenceSet() + + walkChain := func(seqKey store.GimmickSequenceKey) { + chain := chains[seqKey.GimmickSequenceId] + if len(chain) == 0 { + chain = []int32{seqKey.GimmickSequenceId} + } + for _, seqId := range chain { + for _, ref := range refs[seqId] { + addKey(seqKey, seqId, ref) + } + } + } + + var nonHidden []store.GimmickSequenceKey + for seqKey := range user.Gimmick.Sequences { + if hiddenSeq[seqKey.GimmickSequenceId] { + walkChain(seqKey) + } else { + nonHidden = append(nonHidden, seqKey) + } + } + for _, seqKey := range nonHidden { + if sizeFn() >= cap { + break + } + walkChain(seqKey) + } +} + func sortedGimmickRecords(user store.UserState) []map[string]any { - keys := make([]store.GimmickKey, 0, len(user.Gimmick.Progress)) + + keySet := make(map[store.GimmickKey]struct{}) + // Real progress rows (genuine user data) — always kept. for key := range user.Gimmick.Progress { + keySet[key] = struct{}{} + } + projectActiveChainOrnaments(user, + func(seqKey store.GimmickSequenceKey, seqId int32, ref masterdata.GimmickOrnamentRef) { + keySet[store.GimmickKey{ + GimmickSequenceScheduleId: seqKey.GimmickSequenceScheduleId, + GimmickSequenceId: seqId, + GimmickId: ref.GimmickId, + }] = struct{}{} + }, + func() int { return len(keySet) }, + masterdata.MaxUserGimmickRows, + ) + + keys := make([]store.GimmickKey, 0, len(keySet)) + for key := range keySet { keys = append(keys, key) } sort.Slice(keys, func(i, j int) bool { @@ -37,57 +103,103 @@ func sortedGimmickRecords(user store.UserState) []map[string]any { records := make([]map[string]any, 0, len(keys)) for _, key := range keys { - row := user.Gimmick.Progress[key] + isGimmickCleared := false + startDatetime := user.GameStartDatetime + latestVersion := user.GameStartDatetime + if row, ok := user.Gimmick.Progress[key]; ok { + isGimmickCleared = row.IsGimmickCleared + startDatetime = row.StartDatetime + latestVersion = row.LatestVersion + } records = append(records, map[string]any{ "userId": user.UserId, - "gimmickSequenceScheduleId": row.Key.GimmickSequenceScheduleId, - "gimmickSequenceId": row.Key.GimmickSequenceId, - "gimmickId": row.Key.GimmickId, - "isGimmickCleared": row.IsGimmickCleared, - "startDatetime": row.StartDatetime, - "latestVersion": row.LatestVersion, + "gimmickSequenceScheduleId": key.GimmickSequenceScheduleId, + "gimmickSequenceId": key.GimmickSequenceId, + "gimmickId": key.GimmickId, + "isGimmickCleared": isGimmickCleared, + "startDatetime": startDatetime, + "latestVersion": latestVersion, }) } return records } func sortedGimmickOrnamentProgressRecords(user store.UserState) []map[string]any { - keys := make([]store.GimmickOrnamentKey, 0, len(user.Gimmick.OrnamentProgress)) + + keySet := make(map[store.GimmickOrnamentKey]struct{}) + // Real progress rows (genuine user data) — always kept. for key := range user.Gimmick.OrnamentProgress { + keySet[key] = struct{}{} + } + projectActiveChainOrnaments(user, + func(seqKey store.GimmickSequenceKey, seqId int32, ref masterdata.GimmickOrnamentRef) { + keySet[store.GimmickOrnamentKey{ + GimmickSequenceScheduleId: seqKey.GimmickSequenceScheduleId, + GimmickSequenceId: seqId, + GimmickId: ref.GimmickId, + GimmickOrnamentIndex: ref.OrnamentIndex, + }] = struct{}{} + }, + func() int { return len(keySet) }, + masterdata.MaxUserGimmickRows, + ) + + keys := make([]store.GimmickOrnamentKey, 0, len(keySet)) + for key := range keySet { keys = append(keys, key) } sort.Slice(keys, func(i, j int) bool { return compareGimmickOrnamentKey(keys[i], keys[j]) < 0 }) + birdG := birdGimmicks() records := make([]map[string]any, 0, len(keys)) for _, key := range keys { - row := user.Gimmick.OrnamentProgress[key] + progressValueBit := int32(0) + baseDatetime := user.GameStartDatetime + latestVersion := user.GameStartDatetime + if row, ok := user.Gimmick.OrnamentProgress[key]; ok { + progressValueBit = row.ProgressValueBit + baseDatetime = row.BaseDatetime + latestVersion = row.LatestVersion + } else if birdG[key.GimmickId] { + baseDatetime = birdDefaultBaseDatetime + } records = append(records, map[string]any{ "userId": user.UserId, - "gimmickSequenceScheduleId": row.Key.GimmickSequenceScheduleId, - "gimmickSequenceId": row.Key.GimmickSequenceId, - "gimmickId": row.Key.GimmickId, - "gimmickOrnamentIndex": row.Key.GimmickOrnamentIndex, - "progressValueBit": row.ProgressValueBit, - "baseDatetime": row.BaseDatetime, - "latestVersion": row.LatestVersion, + "gimmickSequenceScheduleId": key.GimmickSequenceScheduleId, + "gimmickSequenceId": key.GimmickSequenceId, + "gimmickId": key.GimmickId, + "gimmickOrnamentIndex": key.GimmickOrnamentIndex, + "progressValueBit": progressValueBit, + "baseDatetime": baseDatetime, + "latestVersion": latestVersion, }) } return records } func sortedGimmickSequenceRecords(user store.UserState) []map[string]any { + + ranks := gimmickSequenceRanks() + keys := make([]store.GimmickSequenceKey, 0, len(user.Gimmick.Sequences)) for key := range user.Gimmick.Sequences { keys = append(keys, key) } sort.Slice(keys, func(i, j int) bool { + ri, rj := ranks[keys[i].GimmickSequenceId], ranks[keys[j].GimmickSequenceId] + if ri != rj { + return ri < rj + } if keys[i].GimmickSequenceScheduleId != keys[j].GimmickSequenceScheduleId { return keys[i].GimmickSequenceScheduleId < keys[j].GimmickSequenceScheduleId } return keys[i].GimmickSequenceId < keys[j].GimmickSequenceId }) + if len(keys) > masterdata.MaxUserGimmickRows { + keys = keys[:masterdata.MaxUserGimmickRows] + } records := make([]map[string]any, 0, len(keys)) for _, key := range keys { diff --git a/server/internal/userdata/proj_quest.go b/server/internal/userdata/proj_quest.go index c91b50f..68e38fc 100644 --- a/server/internal/userdata/proj_quest.go +++ b/server/internal/userdata/proj_quest.go @@ -33,8 +33,26 @@ func sortedQuestRecords(user store.UserState) []map[string]any { } func sortedQuestMissionRecords(user store.UserState) []map[string]any { - keys := make([]store.QuestMissionKey, 0, len(user.QuestMissions)) - for key := range user.QuestMissions { + questMissions := make(map[store.QuestMissionKey]store.UserQuestMissionState, len(user.QuestMissions)) + for key, qm := range user.QuestMissions { + questMissions[key] = qm + } + // Force-clear hidden-story quest-missions so their report gimmicks unlock. + for _, key := range hiddenStoryRequirements().QuestMissions { + if existing, ok := questMissions[key]; ok && existing.IsClear { + continue + } + questMissions[key] = store.UserQuestMissionState{ + QuestId: key.QuestId, + QuestMissionId: key.QuestMissionId, + IsClear: true, + LatestClearDatetime: user.GameStartDatetime, + LatestVersion: user.GameStartDatetime, + } + } + + keys := make([]store.QuestMissionKey, 0, len(questMissions)) + for key := range questMissions { keys = append(keys, key) } sort.Slice(keys, func(i, j int) bool { @@ -45,7 +63,7 @@ func sortedQuestMissionRecords(user store.UserState) []map[string]any { }) records := make([]map[string]any, 0, len(keys)) for _, key := range keys { - row := user.QuestMissions[key] + row := questMissions[key] records = append(records, map[string]any{ "userId": user.UserId, "questId": row.QuestId, diff --git a/server/internal/userdata/proj_user.go b/server/internal/userdata/proj_user.go index b841220..439984b 100644 --- a/server/internal/userdata/proj_user.go +++ b/server/internal/userdata/proj_user.go @@ -2,8 +2,11 @@ package userdata import ( "sort" + "sync" "lunar-tear/server/internal/gametime" + "lunar-tear/server/internal/masterdata" + "lunar-tear/server/internal/model" "lunar-tear/server/internal/store" "lunar-tear/server/internal/utils" ) @@ -192,16 +195,35 @@ func sortedTutorialRecords(user store.UserState) []map[string]any { return records } +var hiddenStoryRequirements = sync.OnceValue(masterdata.LoadHiddenStoryRequirements) + func sortedMissionRecords(user store.UserState) []map[string]any { - ids := make([]int, 0, len(user.Missions)) - for id := range user.Missions { + missions := make(map[int32]store.UserMissionState, len(user.Missions)) + for id, m := range user.Missions { + missions[id] = m + } + for _, missionId := range hiddenStoryRequirements().MissionIds { + if existing, ok := missions[missionId]; ok && existing.MissionProgressStatusType >= int32(model.MissionProgressStatusTypeClear) { + continue + } + missions[missionId] = store.UserMissionState{ + MissionId: missionId, + StartDatetime: user.GameStartDatetime, + MissionProgressStatusType: int32(model.MissionProgressStatusTypeClear), + ClearDatetime: user.GameStartDatetime, + LatestVersion: user.GameStartDatetime, + } + } + + ids := make([]int, 0, len(missions)) + for id := range missions { ids = append(ids, int(id)) } sort.Ints(ids) records := make([]map[string]any, 0, len(ids)) for _, id := range ids { - row := user.Missions[int32(id)] + row := missions[int32(id)] records = append(records, map[string]any{ "userId": user.UserId, "missionId": row.MissionId,