Initial commit

This commit is contained in:
Ilya Groshev
2026-04-14 09:28:26 +03:00
commit 02f511f40c
161 changed files with 21541 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
package memory
import (
"maps"
"lunar-tear/server/internal/store"
)
func cloneUserState(u store.UserState) store.UserState {
out := u
out.Tutorials = maps.Clone(u.Tutorials)
out.Characters = maps.Clone(u.Characters)
out.Costumes = maps.Clone(u.Costumes)
out.Weapons = maps.Clone(u.Weapons)
out.Companions = maps.Clone(u.Companions)
out.Thoughts = maps.Clone(u.Thoughts)
out.DeckCharacters = maps.Clone(u.DeckCharacters)
out.DeckSubWeapons = maps.Clone(u.DeckSubWeapons)
out.DeckParts = cloneSliceMap(u.DeckParts)
out.Decks = maps.Clone(u.Decks)
out.Quests = maps.Clone(u.Quests)
out.QuestMissions = maps.Clone(u.QuestMissions)
out.WeaponStories = maps.Clone(u.WeaponStories)
out.Missions = maps.Clone(u.Missions)
out.Gimmick = store.GimmickState{
Progress: maps.Clone(u.Gimmick.Progress),
OrnamentProgress: maps.Clone(u.Gimmick.OrnamentProgress),
Sequences: maps.Clone(u.Gimmick.Sequences),
Unlocks: maps.Clone(u.Gimmick.Unlocks),
}
out.CageOrnamentRewards = maps.Clone(u.CageOrnamentRewards)
out.ConsumableItems = maps.Clone(u.ConsumableItems)
out.Materials = maps.Clone(u.Materials)
out.Parts = maps.Clone(u.Parts)
out.PartsGroupNotes = maps.Clone(u.PartsGroupNotes)
out.PartsPresets = maps.Clone(u.PartsPresets)
out.ImportantItems = maps.Clone(u.ImportantItems)
out.CostumeActiveSkills = maps.Clone(u.CostumeActiveSkills)
out.WeaponSkills = cloneSliceMap(u.WeaponSkills)
out.WeaponAbilities = cloneSliceMap(u.WeaponAbilities)
out.DeckTypeNotes = maps.Clone(u.DeckTypeNotes)
out.WeaponNotes = maps.Clone(u.WeaponNotes)
out.NaviCutInPlayed = maps.Clone(u.NaviCutInPlayed)
out.ViewedMovies = maps.Clone(u.ViewedMovies)
out.ContentsStories = maps.Clone(u.ContentsStories)
out.DrawnOmikuji = maps.Clone(u.DrawnOmikuji)
out.PremiumItems = maps.Clone(u.PremiumItems)
out.DokanConfirmed = maps.Clone(u.DokanConfirmed)
out.ShopItems = maps.Clone(u.ShopItems)
out.ShopReplaceableLineup = maps.Clone(u.ShopReplaceableLineup)
out.Explore = u.Explore
out.ExploreScores = maps.Clone(u.ExploreScores)
out.Gacha = store.GachaState{
RewardAvailable: u.Gacha.RewardAvailable,
TodaysCurrentDrawCount: u.Gacha.TodaysCurrentDrawCount,
DailyMaxCount: u.Gacha.DailyMaxCount,
LastRewardDrawDate: u.Gacha.LastRewardDrawDate,
ConvertedGachaMedal: store.ConvertedGachaMedalState{
ConvertedMedalPossession: append([]store.ConsumableItemState(nil), u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession...),
ObtainPossession: cloneConsumableItemPtr(u.Gacha.ConvertedGachaMedal.ObtainPossession),
},
BannerStates: cloneBannerStates(u.Gacha.BannerStates),
}
out.Gifts = store.GiftState{
NotReceived: cloneNotReceivedGifts(u.Gifts.NotReceived),
Received: cloneReceivedGifts(u.Gifts.Received),
}
out.Battle = u.Battle
out.CostumeAwakenStatusUps = maps.Clone(u.CostumeAwakenStatusUps)
out.AutoSaleSettings = maps.Clone(u.AutoSaleSettings)
out.CharacterRebirths = maps.Clone(u.CharacterRebirths)
return out
}
func cloneGachaCatalogEntry(entry store.GachaCatalogEntry) store.GachaCatalogEntry {
out := entry
out.PricePhases = append([]store.GachaPricePhaseEntry(nil), entry.PricePhases...)
out.PromotionItems = append([]store.GachaPromotionItem(nil), entry.PromotionItems...)
return out
}
func cloneBannerStates(m map[int32]store.GachaBannerState) map[int32]store.GachaBannerState {
if m == nil {
return nil
}
out := make(map[int32]store.GachaBannerState, len(m))
for k, v := range m {
bs := v
bs.BoxDrewCounts = maps.Clone(v.BoxDrewCounts)
out[k] = bs
}
return out
}
func cloneConsumableItemPtr(item *store.ConsumableItemState) *store.ConsumableItemState {
if item == nil {
return nil
}
out := *item
return &out
}
func cloneNotReceivedGifts(gifts []store.NotReceivedGiftState) []store.NotReceivedGiftState {
out := make([]store.NotReceivedGiftState, len(gifts))
for i, gift := range gifts {
out[i] = store.NotReceivedGiftState{
GiftCommon: store.GiftCommonState{
PossessionType: gift.GiftCommon.PossessionType,
PossessionId: gift.GiftCommon.PossessionId,
Count: gift.GiftCommon.Count,
GrantDatetime: gift.GiftCommon.GrantDatetime,
DescriptionGiftTextId: gift.GiftCommon.DescriptionGiftTextId,
EquipmentData: append([]byte(nil), gift.GiftCommon.EquipmentData...),
},
ExpirationDatetime: gift.ExpirationDatetime,
UserGiftUuid: gift.UserGiftUuid,
}
}
return out
}
func cloneSliceMap[T any](m map[string][]T) map[string][]T {
if m == nil {
return nil
}
out := make(map[string][]T, len(m))
for k, v := range m {
out[k] = append([]T(nil), v...)
}
return out
}
func cloneReceivedGifts(gifts []store.ReceivedGiftState) []store.ReceivedGiftState {
out := make([]store.ReceivedGiftState, len(gifts))
for i, gift := range gifts {
out[i] = store.ReceivedGiftState{
GiftCommon: store.GiftCommonState{
PossessionType: gift.GiftCommon.PossessionType,
PossessionId: gift.GiftCommon.PossessionId,
Count: gift.GiftCommon.Count,
GrantDatetime: gift.GiftCommon.GrantDatetime,
DescriptionGiftTextId: gift.GiftCommon.DescriptionGiftTextId,
EquipmentData: append([]byte(nil), gift.GiftCommon.EquipmentData...),
},
ReceivedDatetime: gift.ReceivedDatetime,
}
}
return out
}
+198
View File
@@ -0,0 +1,198 @@
package memory
import (
"fmt"
"strings"
"sync"
"time"
"lunar-tear/server/internal/store"
)
type Option func(*MemoryStore)
func WithSnapshotDir(dir string) Option {
return func(s *MemoryStore) {
s.snapshotDir = dir
}
}
func WithSceneId(sceneId int32) Option {
return func(s *MemoryStore) {
s.bootstrapSceneId = sceneId
}
}
func WithStarterItems(v bool) Option {
return func(s *MemoryStore) {
s.starterItems = v
}
}
type MemoryStore struct {
mu sync.RWMutex
clock store.Clock
bootstrapSceneId int32
snapshotDir string
starterItems bool
lastSnapshotSceneId int32
nextUserId int64
users map[int64]*store.UserState
userIdsByUuid map[string]int64
sessionToUserId map[string]int64
sessions map[string]store.SessionState
gachaCatalog map[int32]store.GachaCatalogEntry
}
var (
_ store.UserRepository = (*MemoryStore)(nil)
_ store.SessionRepository = (*MemoryStore)(nil)
_ store.GachaRepository = (*MemoryStore)(nil)
)
func New(clock store.Clock, options ...Option) *MemoryStore {
if clock == nil {
clock = time.Now
}
s := &MemoryStore{
clock: clock,
nextUserId: defaultUserId,
users: make(map[int64]*store.UserState),
userIdsByUuid: make(map[string]int64),
sessionToUserId: make(map[string]int64),
sessions: make(map[string]store.SessionState),
gachaCatalog: make(map[int32]store.GachaCatalogEntry),
}
for _, opt := range options {
opt(s)
}
return s
}
func (s *MemoryStore) EnsureUser(uuid string) (store.UserState, error) {
s.mu.Lock()
defer s.mu.Unlock()
return cloneUserState(*s.getOrCreateLocked(normalizeUUID(uuid))), nil
}
func (s *MemoryStore) CreateSession(uuid string, ttl time.Duration) (store.UserState, store.SessionState, error) {
s.mu.Lock()
defer s.mu.Unlock()
user := s.getOrCreateLocked(normalizeUUID(uuid))
now := s.clock()
session := store.SessionState{
SessionKey: fmt.Sprintf("session_%d_%d", user.UserId, now.UnixNano()),
UserId: user.UserId,
Uuid: user.Uuid,
ExpireAt: now.Add(ttl),
}
s.sessionToUserId[session.SessionKey] = user.UserId
s.sessions[session.SessionKey] = session
return cloneUserState(*user), session, nil
}
func (s *MemoryStore) ResolveUserId(sessionKey string) (int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
userId, ok := s.sessionToUserId[sessionKey]
if !ok {
return 0, store.ErrNotFound
}
return userId, nil
}
func (s *MemoryStore) SnapshotUser(userId int64) (store.UserState, error) {
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[userId]
if !ok {
return store.UserState{}, store.ErrNotFound
}
return cloneUserState(*user), nil
}
func (s *MemoryStore) UpdateUser(userId int64, mutate func(*store.UserState)) (store.UserState, error) {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[userId]
if !ok {
return store.UserState{}, store.ErrNotFound
}
mutate(user)
sceneId := user.MainQuest.CurrentQuestSceneId
if s.snapshotDir != "" && sceneId != 0 && sceneId != s.lastSnapshotSceneId {
saveSnapshot(user, s.snapshotDir)
s.lastSnapshotSceneId = sceneId
}
return cloneUserState(*user), nil
}
func (s *MemoryStore) DefaultUserId() (int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if _, ok := s.users[defaultUserId]; ok {
return defaultUserId, nil
}
if len(s.users) == 0 {
return defaultUserId, nil
}
var minUserId int64
for userId := range s.users {
if minUserId == 0 || userId < minUserId {
minUserId = userId
}
}
return minUserId, nil
}
func (s *MemoryStore) SnapshotCatalog() ([]store.GachaCatalogEntry, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]store.GachaCatalogEntry, 0, len(s.gachaCatalog))
for _, entry := range s.gachaCatalog {
out = append(out, cloneGachaCatalogEntry(entry))
}
return out, nil
}
func (s *MemoryStore) ReplaceCatalog(entries []store.GachaCatalogEntry) error {
s.mu.Lock()
defer s.mu.Unlock()
s.gachaCatalog = make(map[int32]store.GachaCatalogEntry, len(entries))
for _, entry := range entries {
s.gachaCatalog[entry.GachaId] = cloneGachaCatalogEntry(entry)
}
return nil
}
func (s *MemoryStore) getOrCreateLocked(uuid string) *store.UserState {
if userId, ok := s.userIdsByUuid[uuid]; ok {
return s.users[userId]
}
userId := s.nextUserId
s.nextUserId++
user := seedUserState(userId, uuid, s.clock().UnixMilli(), s.bootstrapSceneId, s.snapshotDir, s.starterItems)
s.users[userId] = user
s.userIdsByUuid[uuid] = userId
return user
}
func normalizeUUID(uuid string) string {
uuid = strings.TrimSpace(uuid)
if uuid == "" {
return defaultUUID
}
return uuid
}
+222
View File
@@ -0,0 +1,222 @@
package memory
import (
"fmt"
"log"
"time"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
)
const (
defaultUUID = "default-user"
defaultUserId = int64(1001)
starterMissionId = int32(1)
starterMainQuestRouteId = int32(1)
starterMainQuestSeasonId = int32(1)
missionInProgress = int32(1)
giftUUIDPrefix = "default-gift"
defaultBirthYear = int32(2000)
defaultBirthMonth = int32(1)
defaultBackupToken = "mock-backup-token"
defaultChargeMoneyThisMonth = int64(0)
)
type starterItemDef struct {
Type model.PossessionType
Id int32
Qty int32
}
var defaultStarterItems = []starterItemDef{
{Type: model.PossessionTypeFreeGem, Id: 0, Qty: 300},
{Type: model.PossessionTypeConsumableItem, Id: 9001, Qty: 1000},
{Type: model.PossessionTypeConsumableItem, Id: model.ConsumableIdChapterTicket, Qty: 1000},
{Type: model.PossessionTypeConsumableItem, Id: 5001, Qty: 1000},
{Type: model.PossessionTypeConsumableItem, Id: 5002, Qty: 1000},
{Type: model.PossessionTypeConsumableItem, Id: 5003, Qty: 1000},
{Type: model.PossessionTypeConsumableItem, Id: 1009, Qty: 1000},
}
func seedUserState(userId int64, uuid string, nowMillis int64, sceneId int32, snapshotDir string, grantStarterItems bool) *store.UserState {
if sceneId != 0 && snapshotDir != "" {
user, err := loadSnapshot(snapshotDir, sceneId)
if err != nil {
log.Fatalf("[bootstrap] no snapshot for scene=%d: %v", sceneId, err)
}
log.Printf("[bootstrap] loaded snapshot for scene=%d", sceneId)
if grantStarterItems {
applyStarterItems(user)
}
return user
}
user := &store.UserState{
UserId: userId,
Uuid: uuid,
PlayerId: userId,
OsType: 2,
PlatformType: 2,
UserRestrictionType: 0,
RegisterDatetime: nowMillis,
GameStartDatetime: nowMillis,
LatestVersion: 0,
BirthYear: defaultBirthYear,
BirthMonth: defaultBirthMonth,
BackupToken: defaultBackupToken,
ChargeMoneyThisMonth: defaultChargeMoneyThisMonth,
Setting: store.UserSettingState{
IsNotifyPurchaseAlert: false,
LatestVersion: 0,
},
Status: store.UserStatusState{
Level: 1,
Exp: 0,
StaminaMilliValue: 50000,
StaminaUpdateDatetime: nowMillis,
LatestVersion: 0,
},
Gem: store.UserGemState{
PaidGem: 10000,
FreeGem: 10000,
},
Profile: store.UserProfileState{
Name: "",
NameUpdateDatetime: 0,
Message: "",
MessageUpdateDatetime: nowMillis,
FavoriteCostumeId: 0,
FavoriteCostumeIdUpdateDatetime: nowMillis,
LatestVersion: 0,
},
Login: store.UserLoginState{
TotalLoginCount: 1,
ContinualLoginCount: 1,
MaxContinualLoginCount: 1,
LastLoginDatetime: nowMillis,
LastComebackLoginDatetime: 0,
LatestVersion: 0,
},
LoginBonus: store.UserLoginBonusState{
LoginBonusId: 1,
CurrentPageNumber: 1,
CurrentStampNumber: 0,
LatestRewardReceiveDatetime: 0,
LatestVersion: 0,
},
Tutorials: map[int32]store.TutorialProgressState{
1: {TutorialType: 1},
},
Battle: store.BattleState{},
Gifts: store.GiftState{
NotReceived: []store.NotReceivedGiftState{
{
GiftCommon: store.GiftCommonState{
PossessionType: int32(model.PossessionTypeFreeGem),
PossessionId: 0,
Count: 300,
GrantDatetime: nowMillis,
},
ExpirationDatetime: nowMillis + int64((7*24*time.Hour)/time.Millisecond),
UserGiftUuid: fmt.Sprintf("%s-%d-1", giftUUIDPrefix, userId),
},
},
Received: []store.ReceivedGiftState{},
},
Gacha: store.GachaState{
ConvertedGachaMedal: store.ConvertedGachaMedalState{
ConvertedMedalPossession: []store.ConsumableItemState{},
},
BannerStates: make(map[int32]store.GachaBannerState),
},
MainQuest: store.MainQuestState{
CurrentMainQuestRouteId: starterMainQuestRouteId,
MainQuestSeasonId: starterMainQuestSeasonId,
},
Notifications: store.NotificationState{
GiftNotReceiveCount: 1,
},
Characters: make(map[int32]store.CharacterState),
Costumes: make(map[string]store.CostumeState),
Weapons: make(map[string]store.WeaponState),
Companions: make(map[string]store.CompanionState),
DeckCharacters: make(map[string]store.DeckCharacterState),
Decks: make(map[store.DeckKey]store.DeckState),
DeckSubWeapons: make(map[string][]string),
DeckParts: make(map[string][]string),
Quests: make(map[int32]store.UserQuestState),
QuestMissions: make(map[store.QuestMissionKey]store.UserQuestMissionState),
SideStoryQuests: make(map[int32]store.SideStoryQuestProgress),
QuestLimitContentStatus: make(map[int32]store.QuestLimitContentStatus),
BigHuntMaxScores: make(map[int32]store.BigHuntMaxScore),
BigHuntStatuses: make(map[int32]store.BigHuntStatus),
BigHuntScheduleMaxScores: make(map[store.BigHuntScheduleScoreKey]store.BigHuntScheduleMaxScore),
BigHuntWeeklyMaxScores: make(map[store.BigHuntWeeklyScoreKey]store.BigHuntWeeklyMaxScore),
BigHuntWeeklyStatuses: make(map[int64]store.BigHuntWeeklyStatus),
WeaponStories: make(map[int32]store.WeaponStoryState),
Missions: map[int32]store.UserMissionState{
starterMissionId: {
MissionId: starterMissionId,
StartDatetime: nowMillis,
MissionProgressStatusType: missionInProgress,
},
},
Gimmick: store.GimmickState{
Progress: make(map[store.GimmickKey]store.GimmickProgressState),
OrnamentProgress: make(map[store.GimmickOrnamentKey]store.GimmickOrnamentProgressState),
Sequences: make(map[store.GimmickSequenceKey]store.GimmickSequenceState),
Unlocks: make(map[store.GimmickKey]store.GimmickUnlockState),
},
CageOrnamentRewards: make(map[int32]store.CageOrnamentRewardState),
ConsumableItems: make(map[int32]int32),
Materials: make(map[int32]int32),
Thoughts: make(map[string]store.ThoughtState),
Parts: make(map[string]store.PartsState),
PartsGroupNotes: make(map[int32]store.PartsGroupNoteState),
PartsPresets: make(map[int32]store.PartsPresetState),
ImportantItems: make(map[int32]int32),
CostumeActiveSkills: make(map[string]store.CostumeActiveSkillState),
WeaponSkills: make(map[string][]store.WeaponSkillState),
WeaponAbilities: make(map[string][]store.WeaponAbilityState),
DeckTypeNotes: make(map[model.DeckType]store.DeckTypeNoteState),
WeaponNotes: make(map[int32]store.WeaponNoteState),
NaviCutInPlayed: make(map[int32]bool),
ViewedMovies: make(map[int32]int64),
ContentsStories: make(map[int32]int64),
DrawnOmikuji: make(map[int32]int64),
PremiumItems: make(map[int32]int64),
DokanConfirmed: make(map[int32]bool),
ShopItems: make(map[int32]store.UserShopItemState),
ShopReplaceableLineup: make(map[int32]store.UserShopReplaceableLineupState),
ExploreScores: make(map[int32]store.ExploreScoreState),
CharacterBoards: make(map[int32]store.CharacterBoardState),
CharacterBoardAbilities: make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState),
CharacterBoardStatusUps: make(map[store.CharacterBoardStatusUpKey]store.CharacterBoardStatusUpState),
CostumeAwakenStatusUps: make(map[store.CostumeAwakenStatusKey]store.CostumeAwakenStatusUpState),
AutoSaleSettings: make(map[int32]store.AutoSaleSettingState),
CharacterRebirths: make(map[int32]store.CharacterRebirthState),
}
store.EnsureDefaultDeck(user, nowMillis)
if grantStarterItems {
applyStarterItems(user)
}
return user
}
func applyStarterItems(user *store.UserState) {
for _, item := range defaultStarterItems {
switch item.Type {
case model.PossessionTypeFreeGem:
user.Gem.FreeGem += item.Qty
case model.PossessionTypeConsumableItem:
user.ConsumableItems[item.Id] += item.Qty
case model.PossessionTypeMaterial:
user.Materials[item.Id] += item.Qty
}
}
}
+47
View File
@@ -0,0 +1,47 @@
package memory
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"lunar-tear/server/internal/store"
)
func snapshotPath(dir string, sceneId int32) string {
return filepath.Join(dir, fmt.Sprintf("scene_%d.json", sceneId))
}
func saveSnapshot(user *store.UserState, dir string) {
sceneId := user.MainQuest.CurrentQuestSceneId
if sceneId == 0 {
return
}
data, err := json.MarshalIndent(user, "", " ")
if err != nil {
log.Printf("[snapshot] marshal error for scene=%d: %v", sceneId, err)
return
}
path := snapshotPath(dir, sceneId)
if err := os.WriteFile(path, data, 0644); err != nil {
log.Printf("[snapshot] write error for scene=%d: %v", sceneId, err)
return
}
log.Printf("[snapshot] saved scene=%d (%d bytes)", sceneId, len(data))
}
func loadSnapshot(dir string, sceneId int32) (*store.UserState, error) {
path := snapshotPath(dir, sceneId)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read snapshot scene=%d: %w", sceneId, err)
}
var user store.UserState
if err := json.Unmarshal(data, &user); err != nil {
return nil, fmt.Errorf("unmarshal snapshot scene=%d: %w", sceneId, err)
}
user.EnsureMaps()
return &user, nil
}