From 4dc722c5d34cd35d99c43a571777ef17aee0280e Mon Sep 17 00:00:00 2001 From: Ilya Groshev Date: Wed, 22 Apr 2026 14:03:26 +0300 Subject: [PATCH] Implement memoir sub-status system with level-based unlocks --- server/internal/masterdata/numericalfunc.go | 2 + server/internal/masterdata/parts.go | 68 +++++++++++++++++++ server/internal/service/parts.go | 50 ++++++++++++++ server/internal/store/clone.go | 1 + server/internal/store/seed.go | 1 + server/internal/store/sqlite/load.go | 11 +++ server/internal/store/sqlite/save.go | 18 +++++ server/internal/store/sqlite/user.go | 1 + server/internal/store/types.go | 20 ++++++ server/internal/userdata/changed_tables.go | 5 ++ server/internal/userdata/proj_inventory.go | 34 +++++++++- .../20260422100818_add_parts_status_subs.sql | 18 +++++ 12 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 server/migrations/20260422100818_add_parts_status_subs.sql diff --git a/server/internal/masterdata/numericalfunc.go b/server/internal/masterdata/numericalfunc.go index 55ad7fd..366a1d8 100644 --- a/server/internal/masterdata/numericalfunc.go +++ b/server/internal/masterdata/numericalfunc.go @@ -39,6 +39,8 @@ func (f NumericalFunc) Evaluate(value int32) int32 { p[1]*value*value/1000 + p[2]*value/1000 + p[3] + case model.NumericalFunctionTypePartsMainOption: + return p[0]*value/1000 + p[1] default: return 0 } diff --git a/server/internal/masterdata/parts.go b/server/internal/masterdata/parts.go index f2b00c8..b53686e 100644 --- a/server/internal/masterdata/parts.go +++ b/server/internal/masterdata/parts.go @@ -7,6 +7,13 @@ import ( "lunar-tear/server/internal/utils" ) +type PartsStatusMainDef struct { + StatusKindType int32 + StatusCalculationType int32 + StatusChangeInitialValue int32 + StatusNumericalFunctionId int32 +} + type PartsCatalog struct { PartsById map[int32]EntityMParts DefaultPartsStatusMainByLotteryGroup map[int32]int32 @@ -14,6 +21,11 @@ type PartsCatalog struct { RateByGroupAndLevel map[int32]map[int32]int32 PriceByGroupAndLevel map[int32]map[int32]int32 SellPriceByRarity map[model.RarityType]NumericalFunc + + PartsStatusMainById map[int32]PartsStatusMainDef + SubStatusPool map[int32][]int32 // lotteryGroupId -> eligible PartsStatusMainIds + SubStatusUnlockLvls map[model.RarityType][]int32 // rarity -> levels where sub-slots unlock + FuncResolver *FunctionResolver } func LoadPartsCatalog() (*PartsCatalog, error) { @@ -83,6 +95,16 @@ func LoadPartsCatalog() (*PartsCatalog, error) { priceByGroupAndLevel[p.PartsLevelUpPriceGroupId][p.LevelLowerLimit] = p.Gold } + partsStatusMainById, subStatusPool := buildPartsStatusMain() + + unlockLvls := []int32{3, 6, 9, 12} + subStatusUnlockLvls := map[model.RarityType][]int32{ + model.RarityNormal: unlockLvls, + model.RarityRare: unlockLvls, + model.RaritySRare: unlockLvls, + model.RaritySSRare: unlockLvls, + } + return &PartsCatalog{ PartsById: partsById, DefaultPartsStatusMainByLotteryGroup: defaultPartsStatusMainByLotteryGroup, @@ -90,5 +112,51 @@ func LoadPartsCatalog() (*PartsCatalog, error) { RateByGroupAndLevel: rateByGroupAndLevel, PriceByGroupAndLevel: priceByGroupAndLevel, SellPriceByRarity: sellPriceByRarity, + PartsStatusMainById: partsStatusMainById, + SubStatusPool: subStatusPool, + SubStatusUnlockLvls: subStatusUnlockLvls, + FuncResolver: funcResolver, }, nil } + +// buildPartsStatusMain constructs the 36 PartsStatusMain definitions and +// groups them into sub-status lottery pools by tier (1-4). +// The data mirrors EntityMPartsStatusMainTable.json which is structured as +// 9 stat categories x 4 tiers. Tier within each category maps to the +// PartsStatusSubLotteryGroupId on the part definition. +func buildPartsStatusMain() (map[int32]PartsStatusMainDef, map[int32][]int32) { + type statCat struct { + kindType int32 + calcType int32 + initVals [4]int32 + funcStart int32 + } + cats := []statCat{ + {2, 1, [4]int32{50, 100, 150, 250}, 101}, // Attack flat + {7, 1, [4]int32{50, 100, 150, 250}, 101}, // Vitality flat + {2, 2, [4]int32{10, 30, 70, 120}, 105}, // Attack % + {7, 2, [4]int32{10, 30, 70, 120}, 105}, // Vitality % + {6, 2, [4]int32{10, 30, 70, 120}, 105}, // HP % + {6, 1, [4]int32{600, 1200, 1800, 3000}, 109}, // HP flat + {4, 1, [4]int32{10, 30, 70, 120}, 113}, // CritRatio + {3, 1, [4]int32{20, 50, 80, 100}, 117}, // CritAttack + {1, 1, [4]int32{10, 20, 30, 40}, 121}, // Agility + } + + defs := make(map[int32]PartsStatusMainDef, 36) + pool := map[int32][]int32{1: {}, 2: {}, 3: {}, 4: {}} + id := int32(1) + for _, c := range cats { + for tier := 0; tier < 4; tier++ { + defs[id] = PartsStatusMainDef{ + StatusKindType: c.kindType, + StatusCalculationType: c.calcType, + StatusChangeInitialValue: c.initVals[tier], + StatusNumericalFunctionId: c.funcStart + int32(tier), + } + pool[int32(tier+1)] = append(pool[int32(tier+1)], id) + id++ + } + } + return defs, pool +} diff --git a/server/internal/service/parts.go b/server/internal/service/parts.go index b83d02c..86177c7 100644 --- a/server/internal/service/parts.go +++ b/server/internal/service/parts.go @@ -59,6 +59,11 @@ func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest) gold := sellFunc.Evaluate(part.Level) totalGold += gold delete(user.Parts, uuid) + for k := range user.PartsStatusSubs { + if k.UserPartsUuid == uuid { + delete(user.PartsStatusSubs, k) + } + } log.Printf("[PartsService] Sell: uuid=%s partsId=%d level=%d -> %d gold", uuid, part.PartsId, part.Level, gold) } @@ -131,6 +136,8 @@ func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRe 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) + + s.grantSubStatuses(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) @@ -148,6 +155,49 @@ func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRe }, nil } +func (s *PartsServiceServer) grantSubStatuses(user *store.UserState, uuid string, part store.PartsState, partDef masterdata.EntityMParts, nowMillis int64) { + unlockLevels := s.catalog.SubStatusUnlockLvls[partDef.RarityType] + pool := s.catalog.SubStatusPool[partDef.PartsStatusSubLotteryGroupId] + if len(pool) == 0 { + return + } + + for slotIdx, lvl := range unlockLevels { + if part.Level != lvl { + continue + } + statusIndex := int32(slotIdx + 1) + key := store.PartsStatusSubKey{UserPartsUuid: uuid, StatusIndex: statusIndex} + if _, exists := user.PartsStatusSubs[key]; exists { + continue + } + + pick := pool[rand.Intn(len(pool))] + def, ok := s.catalog.PartsStatusMainById[pick] + if !ok { + continue + } + + statusValue := def.StatusChangeInitialValue + if f, ok := s.catalog.FuncResolver.Resolve(def.StatusNumericalFunctionId); ok { + statusValue = f.Evaluate(part.Level) + } + + user.PartsStatusSubs[key] = store.PartsStatusSubState{ + UserPartsUuid: uuid, + StatusIndex: statusIndex, + PartsStatusSubLotteryId: pick, + Level: part.Level, + StatusKindType: def.StatusKindType, + StatusCalculationType: def.StatusCalculationType, + StatusChangeValue: statusValue, + LatestVersion: nowMillis, + } + log.Printf("[PartsService] Enhance: granted sub-status slot=%d lotteryId=%d kind=%d calc=%d val=%d", + statusIndex, pick, def.StatusKindType, def.StatusCalculationType, statusValue) + } +} + func (s *PartsServiceServer) ReplacePreset(ctx context.Context, req *pb.PartsReplacePresetRequest) (*pb.PartsReplacePresetResponse, error) { log.Printf("[PartsService] ReplacePreset: preset=%d uuids=[%s, %s, %s]", req.UserPartsPresetNumber, req.UserPartsUuid01, req.UserPartsUuid02, req.UserPartsUuid03) diff --git a/server/internal/store/clone.go b/server/internal/store/clone.go index ff9c3e3..4c3f7e3 100644 --- a/server/internal/store/clone.go +++ b/server/internal/store/clone.go @@ -30,6 +30,7 @@ func CloneUserState(u UserState) UserState { out.Parts = maps.Clone(u.Parts) out.PartsGroupNotes = maps.Clone(u.PartsGroupNotes) out.PartsPresets = maps.Clone(u.PartsPresets) + out.PartsStatusSubs = maps.Clone(u.PartsStatusSubs) out.ImportantItems = maps.Clone(u.ImportantItems) out.CostumeActiveSkills = maps.Clone(u.CostumeActiveSkills) out.WeaponSkills = cloneSliceMap(u.WeaponSkills) diff --git a/server/internal/store/seed.go b/server/internal/store/seed.go index 4e61ca4..feafd7e 100644 --- a/server/internal/store/seed.go +++ b/server/internal/store/seed.go @@ -129,6 +129,7 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl Parts: make(map[string]PartsState), PartsGroupNotes: make(map[int32]PartsGroupNoteState), PartsPresets: make(map[int32]PartsPresetState), + PartsStatusSubs: make(map[PartsStatusSubKey]PartsStatusSubState), ImportantItems: make(map[int32]int32), CostumeActiveSkills: make(map[string]CostumeActiveSkillState), WeaponSkills: make(map[string][]WeaponSkillState), diff --git a/server/internal/store/sqlite/load.go b/server/internal/store/sqlite/load.go index 839d8bb..e384e86 100644 --- a/server/internal/store/sqlite/load.go +++ b/server/internal/store/sqlite/load.go @@ -60,6 +60,7 @@ func initMaps(u *store.UserState) { u.Parts = make(map[string]store.PartsState) u.PartsGroupNotes = make(map[int32]store.PartsGroupNoteState) u.PartsPresets = make(map[int32]store.PartsPresetState) + u.PartsStatusSubs = make(map[store.PartsStatusSubKey]store.PartsStatusSubState) u.DeckTypeNotes = make(map[model.DeckType]store.DeckTypeNoteState) u.ConsumableItems = make(map[int32]int32) u.Materials = make(map[int32]int32) @@ -453,6 +454,16 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) { u.PartsPresets[v.UserPartsPresetNumber] = v }) + queryRows(db, `SELECT user_parts_uuid, status_index, parts_status_sub_lottery_id, level, + status_kind_type, status_calculation_type, status_change_value, latest_version + FROM user_parts_status_subs WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.PartsStatusSubState + rows.Scan(&v.UserPartsUuid, &v.StatusIndex, &v.PartsStatusSubLotteryId, &v.Level, + &v.StatusKindType, &v.StatusCalculationType, &v.StatusChangeValue, &v.LatestVersion) + u.PartsStatusSubs[store.PartsStatusSubKey{UserPartsUuid: v.UserPartsUuid, StatusIndex: v.StatusIndex}] = v + }) + queryRows(db, `SELECT deck_type, max_deck_power, latest_version FROM user_deck_type_notes WHERE user_id=?`, uid, func(rows *sql.Rows) { var dt int32 diff --git a/server/internal/store/sqlite/save.go b/server/internal/store/sqlite/save.go index cd99e9e..238da56 100644 --- a/server/internal/store/sqlite/save.go +++ b/server/internal/store/sqlite/save.go @@ -290,6 +290,12 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error { return err } } + for _, v := range u.PartsStatusSubs { + if err := exec(`INSERT INTO user_parts_status_subs (user_id, user_parts_uuid, status_index, parts_status_sub_lottery_id, level, status_kind_type, status_calculation_type, status_change_value, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`, + uid, v.UserPartsUuid, v.StatusIndex, v.PartsStatusSubLotteryId, v.Level, v.StatusKindType, v.StatusCalculationType, v.StatusChangeValue, v.LatestVersion); err != nil { + return err + } + } for _, v := range u.DeckTypeNotes { if err := exec(`INSERT INTO user_deck_type_notes (user_id, deck_type, max_deck_power, latest_version) VALUES (?,?,?,?)`, uid, int32(v.DeckType), v.MaxDeckPower, v.LatestVersion); err != nil { @@ -808,6 +814,18 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { return []any{v.UserPartsPresetNumber, v.UserPartsUuid01, v.UserPartsUuid02, v.UserPartsUuid03, v.Name, v.UserPartsPresetTagNumber, v.LatestVersion} }, "user_parts_preset_number, user_parts_uuid01, user_parts_uuid02, user_parts_uuid03, name, user_parts_preset_tag_number, latest_version") + for k, v := range after.PartsStatusSubs { + if old, ok := before.PartsStatusSubs[k]; !ok || old != v { + exec(`INSERT OR REPLACE INTO user_parts_status_subs (user_id, user_parts_uuid, status_index, parts_status_sub_lottery_id, level, status_kind_type, status_calculation_type, status_change_value, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`, + uid, k.UserPartsUuid, k.StatusIndex, v.PartsStatusSubLotteryId, v.Level, v.StatusKindType, v.StatusCalculationType, v.StatusChangeValue, v.LatestVersion) + } + } + for k := range before.PartsStatusSubs { + if _, ok := after.PartsStatusSubs[k]; !ok { + exec(`DELETE FROM user_parts_status_subs WHERE user_id=? AND user_parts_uuid=? AND status_index=?`, uid, k.UserPartsUuid, k.StatusIndex) + } + } + // Deck type notes (key is model.DeckType which is int32-based) for k, v := range after.DeckTypeNotes { if old, ok := before.DeckTypeNotes[k]; !ok || old != v { diff --git a/server/internal/store/sqlite/user.go b/server/internal/store/sqlite/user.go index 5bf8217..8441392 100644 --- a/server/internal/store/sqlite/user.go +++ b/server/internal/store/sqlite/user.go @@ -123,6 +123,7 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error { "user_deck_sub_weapons", "user_decks", "user_deck_characters", + "user_parts_status_subs", "user_parts_presets", "user_parts_group_notes", "user_parts", diff --git a/server/internal/store/types.go b/server/internal/store/types.go index 2b0f503..13652c8 100644 --- a/server/internal/store/types.go +++ b/server/internal/store/types.go @@ -80,6 +80,7 @@ type UserState struct { Parts map[string]PartsState PartsGroupNotes map[int32]PartsGroupNoteState PartsPresets map[int32]PartsPresetState + PartsStatusSubs map[PartsStatusSubKey]PartsStatusSubState ImportantItems map[int32]int32 CostumeActiveSkills map[string]CostumeActiveSkillState WeaponSkills map[string][]WeaponSkillState // key: userWeaponUuid @@ -197,6 +198,9 @@ func (u *UserState) EnsureMaps() { if u.PartsPresets == nil { u.PartsPresets = make(map[int32]PartsPresetState) } + if u.PartsStatusSubs == nil { + u.PartsStatusSubs = make(map[PartsStatusSubKey]PartsStatusSubState) + } if u.ImportantItems == nil { u.ImportantItems = make(map[int32]int32) } @@ -833,6 +837,22 @@ type PartsPresetState struct { LatestVersion int64 } +type PartsStatusSubKey struct { + UserPartsUuid string + StatusIndex int32 +} + +type PartsStatusSubState struct { + UserPartsUuid string + StatusIndex int32 + PartsStatusSubLotteryId int32 + Level int32 + StatusKindType int32 + StatusCalculationType int32 + StatusChangeValue int32 + LatestVersion int64 +} + type NotificationState struct { GiftNotReceiveCount int32 FriendRequestReceiveCount int32 diff --git a/server/internal/userdata/changed_tables.go b/server/internal/userdata/changed_tables.go index f219d78..2f11c87 100644 --- a/server/internal/userdata/changed_tables.go +++ b/server/internal/userdata/changed_tables.go @@ -161,6 +161,9 @@ func ChangedTables(before, after *store.UserState) []string { if !mapsEqualStruct(before.PartsPresets, after.PartsPresets) { add("IUserPartsPreset") } + if !mapsEqualStruct(before.PartsStatusSubs, after.PartsStatusSubs) { + add("IUserPartsStatusSub") + } if !mapsEqualStruct(before.CostumeActiveSkills, after.CostumeActiveSkills) { add("IUserCostumeActiveSkill") } @@ -348,6 +351,8 @@ func keyFieldsForTable(table string) []string { return []string{"userId", "userThoughtUuid"} case "IUserParts": return []string{"userId", "userPartsUuid"} + case "IUserPartsStatusSub": + return []string{"userId", "userPartsUuid", "statusIndex"} case "IUserDeckCharacter": return []string{"userId", "userDeckCharacterUuid"} case "IUserDeck": diff --git a/server/internal/userdata/proj_inventory.go b/server/internal/userdata/proj_inventory.go index e4af5ef..7927e02 100644 --- a/server/internal/userdata/proj_inventory.go +++ b/server/internal/userdata/proj_inventory.go @@ -114,12 +114,15 @@ func init() { s, _ := utils.EncodeJSONMaps(SortedCostumeLotteryEffectPendingRecords(user)...) return s }) + register("IUserPartsStatusSub", func(user store.UserState) string { + s, _ := utils.EncodeJSONMaps(sortedPartsStatusSubRecords(user)...) + return s + }) registerStatic( "IUserCostumeLevelBonusReleaseStatus", "IUserCostumeLotteryEffectAbility", "IUserCostumeLotteryEffectStatusUp", "IUserPartsPresetTag", - "IUserPartsStatusSub", ) } @@ -493,6 +496,35 @@ func sortedPartsPresetRecords(user store.UserState) []map[string]any { return records } +func sortedPartsStatusSubRecords(user store.UserState) []map[string]any { + keys := make([]store.PartsStatusSubKey, 0, len(user.PartsStatusSubs)) + for k := range user.PartsStatusSubs { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].UserPartsUuid != keys[j].UserPartsUuid { + return keys[i].UserPartsUuid < keys[j].UserPartsUuid + } + return keys[i].StatusIndex < keys[j].StatusIndex + }) + records := make([]map[string]any, 0, len(keys)) + for _, k := range keys { + row := user.PartsStatusSubs[k] + records = append(records, map[string]any{ + "userId": user.UserId, + "userPartsUuid": row.UserPartsUuid, + "statusIndex": row.StatusIndex, + "partsStatusSubLotteryId": row.PartsStatusSubLotteryId, + "level": row.Level, + "statusKindType": row.StatusKindType, + "statusCalculationType": row.StatusCalculationType, + "statusChangeValue": row.StatusChangeValue, + "latestVersion": row.LatestVersion, + }) + } + return records +} + func sortedCostumeActiveSkillRecords(user store.UserState) []map[string]any { keys := sortedStringKeys(user.CostumeActiveSkills) records := make([]map[string]any, 0, len(keys)) diff --git a/server/migrations/20260422100818_add_parts_status_subs.sql b/server/migrations/20260422100818_add_parts_status_subs.sql new file mode 100644 index 0000000..9020027 --- /dev/null +++ b/server/migrations/20260422100818_add_parts_status_subs.sql @@ -0,0 +1,18 @@ +-- +goose Up +CREATE TABLE user_parts_status_subs ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_parts_uuid TEXT NOT NULL, + status_index INTEGER NOT NULL, + parts_status_sub_lottery_id INTEGER NOT NULL DEFAULT 0, + level INTEGER NOT NULL DEFAULT 0, + status_kind_type INTEGER NOT NULL DEFAULT 0, + status_calculation_type INTEGER NOT NULL DEFAULT 0, + status_change_value INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_parts_uuid, status_index) +); + +UPDATE user_parts SET level = 1; + +-- +goose Down +DROP TABLE IF EXISTS user_parts_status_subs;