Add SQLite persistence, import-snapshot tool, and karma functionality

This commit is contained in:
Ilya Groshev
2026-04-20 09:57:47 +03:00
parent c9ad3fa4f4
commit c33e738fd5
70 changed files with 4151 additions and 833 deletions
@@ -1,12 +1,8 @@
package memory
package store
import (
"maps"
import "maps"
"lunar-tear/server/internal/store"
)
func cloneUserState(u store.UserState) store.UserState {
func CloneUserState(u UserState) UserState {
out := u
out.Tutorials = maps.Clone(u.Tutorials)
out.Characters = maps.Clone(u.Characters)
@@ -15,14 +11,14 @@ func cloneUserState(u store.UserState) store.UserState {
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.DeckSubWeapons = cloneSliceMap(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{
out.Gimmick = GimmickState{
Progress: maps.Clone(u.Gimmick.Progress),
OrnamentProgress: maps.Clone(u.Gimmick.OrnamentProgress),
Sequences: maps.Clone(u.Gimmick.Sequences),
@@ -38,6 +34,7 @@ func cloneUserState(u store.UserState) store.UserState {
out.CostumeActiveSkills = maps.Clone(u.CostumeActiveSkills)
out.WeaponSkills = cloneSliceMap(u.WeaponSkills)
out.WeaponAbilities = cloneSliceMap(u.WeaponAbilities)
out.WeaponAwakens = maps.Clone(u.WeaponAwakens)
out.DeckTypeNotes = maps.Clone(u.DeckTypeNotes)
out.WeaponNotes = maps.Clone(u.WeaponNotes)
out.NaviCutInPlayed = maps.Clone(u.NaviCutInPlayed)
@@ -50,40 +47,46 @@ func cloneUserState(u store.UserState) store.UserState {
out.ShopReplaceableLineup = maps.Clone(u.ShopReplaceableLineup)
out.Explore = u.Explore
out.ExploreScores = maps.Clone(u.ExploreScores)
out.Gacha = store.GachaState{
out.Gacha = 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...),
ConvertedGachaMedal: ConvertedGachaMedalState{
ConvertedMedalPossession: append([]ConsumableItemState(nil), u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession...),
ObtainPossession: cloneConsumableItemPtr(u.Gacha.ConvertedGachaMedal.ObtainPossession),
},
BannerStates: cloneBannerStates(u.Gacha.BannerStates),
}
out.Gifts = store.GiftState{
out.Gifts = GiftState{
NotReceived: cloneNotReceivedGifts(u.Gifts.NotReceived),
Received: cloneReceivedGifts(u.Gifts.Received),
}
out.Battle = u.Battle
out.SideStoryQuests = maps.Clone(u.SideStoryQuests)
out.QuestLimitContentStatus = maps.Clone(u.QuestLimitContentStatus)
out.BigHuntMaxScores = maps.Clone(u.BigHuntMaxScores)
out.BigHuntStatuses = maps.Clone(u.BigHuntStatuses)
out.BigHuntScheduleMaxScores = maps.Clone(u.BigHuntScheduleMaxScores)
out.BigHuntWeeklyMaxScores = maps.Clone(u.BigHuntWeeklyMaxScores)
out.BigHuntWeeklyStatuses = maps.Clone(u.BigHuntWeeklyStatuses)
out.BigHuntBattleBinary = append([]byte(nil), u.BigHuntBattleBinary...)
out.CharacterBoards = maps.Clone(u.CharacterBoards)
out.CharacterBoardAbilities = maps.Clone(u.CharacterBoardAbilities)
out.CharacterBoardStatusUps = maps.Clone(u.CharacterBoardStatusUps)
out.CostumeAwakenStatusUps = maps.Clone(u.CostumeAwakenStatusUps)
out.CostumeLotteryEffects = maps.Clone(u.CostumeLotteryEffects)
out.CostumeLotteryEffectPending = maps.Clone(u.CostumeLotteryEffectPending)
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 {
func cloneBannerStates(m map[int32]GachaBannerState) map[int32]GachaBannerState {
if m == nil {
return nil
}
out := make(map[int32]store.GachaBannerState, len(m))
out := make(map[int32]GachaBannerState, len(m))
for k, v := range m {
bs := v
bs.BoxDrewCounts = maps.Clone(v.BoxDrewCounts)
@@ -92,7 +95,7 @@ func cloneBannerStates(m map[int32]store.GachaBannerState) map[int32]store.Gacha
return out
}
func cloneConsumableItemPtr(item *store.ConsumableItemState) *store.ConsumableItemState {
func cloneConsumableItemPtr(item *ConsumableItemState) *ConsumableItemState {
if item == nil {
return nil
}
@@ -100,11 +103,11 @@ func cloneConsumableItemPtr(item *store.ConsumableItemState) *store.ConsumableIt
return &out
}
func cloneNotReceivedGifts(gifts []store.NotReceivedGiftState) []store.NotReceivedGiftState {
out := make([]store.NotReceivedGiftState, len(gifts))
func cloneNotReceivedGifts(gifts []NotReceivedGiftState) []NotReceivedGiftState {
out := make([]NotReceivedGiftState, len(gifts))
for i, gift := range gifts {
out[i] = store.NotReceivedGiftState{
GiftCommon: store.GiftCommonState{
out[i] = NotReceivedGiftState{
GiftCommon: GiftCommonState{
PossessionType: gift.GiftCommon.PossessionType,
PossessionId: gift.GiftCommon.PossessionId,
Count: gift.GiftCommon.Count,
@@ -119,6 +122,24 @@ func cloneNotReceivedGifts(gifts []store.NotReceivedGiftState) []store.NotReceiv
return out
}
func cloneReceivedGifts(gifts []ReceivedGiftState) []ReceivedGiftState {
out := make([]ReceivedGiftState, len(gifts))
for i, gift := range gifts {
out[i] = ReceivedGiftState{
GiftCommon: 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
}
func cloneSliceMap[T any](m map[string][]T) map[string][]T {
if m == nil {
return nil
@@ -129,21 +150,3 @@ func cloneSliceMap[T any](m map[string][]T) map[string][]T {
}
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
}
+30 -21
View File
@@ -5,6 +5,8 @@ import (
"log"
"sort"
"github.com/google/uuid"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
)
@@ -139,7 +141,7 @@ func (g *PossessionGranter) GrantCostume(user *UserState, costumeId int32, nowMi
}
}
}
key := fmt.Sprintf("reward-costume-%d", costumeId)
key := uuid.New().String()
user.Costumes[key] = CostumeState{
UserCostumeUuid: key,
CostumeId: costumeId,
@@ -155,16 +157,7 @@ func (g *PossessionGranter) GrantCostume(user *UserState, costumeId int32, nowMi
}
func (g *PossessionGranter) GrantWeapon(user *UserState, weaponId int32, nowMillis int64) {
key := fmt.Sprintf("reward-weapon-%d-%d", weaponId, nowMillis)
if _, exists := user.Weapons[key]; exists {
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s-%d", key, i)
if _, exists := user.Weapons[candidate]; !exists {
key = candidate
break
}
}
}
key := uuid.New().String()
user.Weapons[key] = WeaponState{
UserWeaponUuid: key,
WeaponId: weaponId,
@@ -269,16 +262,29 @@ func EnsureDefaultDeck(user *UserState, nowMillis int64) {
return
}
costumeUuid := FirstSortedKey(user.Costumes)
weaponUuid := FirstSortedKey(user.Weapons)
companionUuid := FirstSortedKey(user.Companions)
const rionCostumeId = int32(10100)
const rionWeaponId = int32(101001)
dcUuid := "default-deck-character-0001"
var costumeUuid, weaponUuid string
for k, v := range user.Costumes {
if v.CostumeId == rionCostumeId {
costumeUuid = k
break
}
}
for k, v := range user.Weapons {
if v.WeaponId == rionWeaponId {
weaponUuid = k
break
}
}
dcUuid := uuid.New().String()
user.DeckCharacters[dcUuid] = DeckCharacterState{
UserDeckCharacterUuid: dcUuid,
UserCompanionUuid: "",
UserCostumeUuid: costumeUuid,
MainUserWeaponUuid: weaponUuid,
UserCompanionUuid: companionUuid,
Power: 100,
LatestVersion: nowMillis,
}
@@ -324,14 +330,17 @@ func ApplyDeckReplacement(user *UserState, deckType model.DeckType, userDeckNumb
deck.Power = 100
}
uuids := []*string{&deck.UserDeckCharacterUuid01, &deck.UserDeckCharacterUuid02, &deck.UserDeckCharacterUuid03}
for i, uuid := range uuids {
uuidPtrs := []*string{&deck.UserDeckCharacterUuid01, &deck.UserDeckCharacterUuid02, &deck.UserDeckCharacterUuid03}
for i, uuidPtr := range uuidPtrs {
if i >= len(slots) || slots[i].UserCostumeUuid == "" {
*uuid = ""
*uuidPtr = ""
continue
}
slot := slots[i]
dcUuid := fmt.Sprintf("deck-%d-%d-%d", deckType, userDeckNumber, i+1)
dcUuid := *uuidPtr
if dcUuid == "" {
dcUuid = uuid.New().String()
}
dc := user.DeckCharacters[dcUuid]
dc.UserDeckCharacterUuid = dcUuid
dc.UserCostumeUuid = slot.UserCostumeUuid
@@ -343,7 +352,7 @@ func ApplyDeckReplacement(user *UserState, deckType model.DeckType, userDeckNumb
user.DeckCharacters[dcUuid] = dc
user.DeckSubWeapons[dcUuid] = slot.SubWeaponUuids
user.DeckParts[dcUuid] = slot.PartsUuids
*uuid = dcUuid
*uuidPtr = dcUuid
}
deck.LatestVersion = nowMillis
-198
View File
@@ -1,198 +0,0 @@
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
@@ -1,222 +0,0 @@
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
}
}
}
-95
View File
@@ -1,95 +0,0 @@
package memory
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"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))
}
// parseSceneId extracts the numeric scene ID from a filename of the form "scene_<id>.json".
// Returns (0, false) if the name does not match the expected format.
func parseSceneId(name string) (int32, bool) {
if !strings.HasPrefix(name, "scene_") || !strings.HasSuffix(name, ".json") {
return 0, false
}
raw := strings.TrimSuffix(strings.TrimPrefix(name, "scene_"), ".json")
id, err := strconv.ParseInt(raw, 10, 32)
if err != nil {
return 0, false
}
return int32(id), true
}
// LatestSnapshotSceneId scans dir for scene_*.json files and returns the scene ID
// of the most recently modified snapshot. Returns (0, false) if none are found.
func LatestSnapshotSceneId(dir string) (int32, bool) {
entries, err := os.ReadDir(dir)
if err != nil {
return 0, false
}
var latestId int32
var latestMod int64
for _, e := range entries {
if e.IsDir() {
continue
}
id, ok := parseSceneId(e.Name())
if !ok {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if mt := info.ModTime().UnixNano(); mt > latestMod {
latestMod = mt
latestId = id
}
}
if latestId == 0 {
return 0, false
}
return latestId, true
}
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
}
+157
View File
@@ -0,0 +1,157 @@
package store
import (
"lunar-tear/server/internal/model"
)
const (
starterMissionId = int32(1)
starterMainQuestRouteId = int32(1)
starterMainQuestSeasonId = int32(1)
missionInProgress = int32(1)
defaultBirthYear = int32(2000)
defaultBirthMonth = int32(1)
defaultBackupToken = "mock-backup-token"
defaultChargeMoneyThisMonth = int64(0)
)
func SeedUserState(userId int64, uuid string, nowMillis int64) *UserState {
user := &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: UserSettingState{
IsNotifyPurchaseAlert: false,
LatestVersion: 0,
},
Status: UserStatusState{
Level: 1,
Exp: 0,
StaminaMilliValue: 50000,
StaminaUpdateDatetime: nowMillis,
LatestVersion: 0,
},
Gem: UserGemState{
PaidGem: 0,
FreeGem: 0,
},
Profile: UserProfileState{
Name: "",
NameUpdateDatetime: 0,
Message: "",
MessageUpdateDatetime: nowMillis,
FavoriteCostumeId: 0,
FavoriteCostumeIdUpdateDatetime: nowMillis,
LatestVersion: 0,
},
Login: UserLoginState{
TotalLoginCount: 1,
ContinualLoginCount: 1,
MaxContinualLoginCount: 1,
LastLoginDatetime: nowMillis,
LastComebackLoginDatetime: 0,
LatestVersion: 0,
},
LoginBonus: UserLoginBonusState{
LoginBonusId: 1,
CurrentPageNumber: 1,
CurrentStampNumber: 0,
LatestRewardReceiveDatetime: 0,
LatestVersion: 0,
},
Tutorials: map[int32]TutorialProgressState{
1: {TutorialType: 1},
},
Battle: BattleState{},
Gifts: GiftState{
NotReceived: []NotReceivedGiftState{},
Received: []ReceivedGiftState{},
},
Gacha: GachaState{
ConvertedGachaMedal: ConvertedGachaMedalState{
ConvertedMedalPossession: []ConsumableItemState{},
},
BannerStates: make(map[int32]GachaBannerState),
},
MainQuest: MainQuestState{
CurrentMainQuestRouteId: starterMainQuestRouteId,
MainQuestSeasonId: starterMainQuestSeasonId,
},
Notifications: NotificationState{
GiftNotReceiveCount: 1,
},
Characters: make(map[int32]CharacterState),
Costumes: make(map[string]CostumeState),
Weapons: make(map[string]WeaponState),
Companions: make(map[string]CompanionState),
DeckCharacters: make(map[string]DeckCharacterState),
Decks: make(map[DeckKey]DeckState),
DeckSubWeapons: make(map[string][]string),
DeckParts: make(map[string][]string),
Quests: make(map[int32]UserQuestState),
QuestMissions: make(map[QuestMissionKey]UserQuestMissionState),
SideStoryQuests: make(map[int32]SideStoryQuestProgress),
QuestLimitContentStatus: make(map[int32]QuestLimitContentStatus),
BigHuntMaxScores: make(map[int32]BigHuntMaxScore),
BigHuntStatuses: make(map[int32]BigHuntStatus),
BigHuntScheduleMaxScores: make(map[BigHuntScheduleScoreKey]BigHuntScheduleMaxScore),
BigHuntWeeklyMaxScores: make(map[BigHuntWeeklyScoreKey]BigHuntWeeklyMaxScore),
BigHuntWeeklyStatuses: make(map[int64]BigHuntWeeklyStatus),
WeaponStories: make(map[int32]WeaponStoryState),
Missions: map[int32]UserMissionState{
starterMissionId: {
MissionId: starterMissionId,
StartDatetime: nowMillis,
MissionProgressStatusType: missionInProgress,
},
},
Gimmick: GimmickState{
Progress: make(map[GimmickKey]GimmickProgressState),
OrnamentProgress: make(map[GimmickOrnamentKey]GimmickOrnamentProgressState),
Sequences: make(map[GimmickSequenceKey]GimmickSequenceState),
Unlocks: make(map[GimmickKey]GimmickUnlockState),
},
CageOrnamentRewards: make(map[int32]CageOrnamentRewardState),
ConsumableItems: make(map[int32]int32),
Materials: make(map[int32]int32),
Thoughts: make(map[string]ThoughtState),
Parts: make(map[string]PartsState),
PartsGroupNotes: make(map[int32]PartsGroupNoteState),
PartsPresets: make(map[int32]PartsPresetState),
ImportantItems: make(map[int32]int32),
CostumeActiveSkills: make(map[string]CostumeActiveSkillState),
WeaponSkills: make(map[string][]WeaponSkillState),
WeaponAbilities: make(map[string][]WeaponAbilityState),
DeckTypeNotes: make(map[model.DeckType]DeckTypeNoteState),
WeaponNotes: make(map[int32]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]UserShopItemState),
ShopReplaceableLineup: make(map[int32]UserShopReplaceableLineupState),
ExploreScores: make(map[int32]ExploreScoreState),
CharacterBoards: make(map[int32]CharacterBoardState),
CharacterBoardAbilities: make(map[CharacterBoardAbilityKey]CharacterBoardAbilityState),
CharacterBoardStatusUps: make(map[CharacterBoardStatusUpKey]CharacterBoardStatusUpState),
CostumeAwakenStatusUps: make(map[CostumeAwakenStatusKey]CostumeAwakenStatusUpState),
AutoSaleSettings: make(map[int32]AutoSaleSettingState),
CharacterRebirths: make(map[int32]CharacterRebirthState),
}
return user
}
+724
View File
@@ -0,0 +1,724 @@
package sqlite
import (
"database/sql"
"fmt"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
)
func (s *SQLiteStore) LoadUser(userId int64) (store.UserState, error) {
var u store.UserState
err := s.db.QueryRow(`SELECT user_id, uuid, player_id, os_type, platform_type, user_restriction_type,
register_datetime, game_start_datetime, latest_version, birth_year, birth_month,
backup_token, charge_money_this_month FROM users WHERE user_id = ?`, userId).Scan(
&u.UserId, &u.Uuid, &u.PlayerId, &u.OsType, &u.PlatformType, &u.UserRestrictionType,
&u.RegisterDatetime, &u.GameStartDatetime, &u.LatestVersion, &u.BirthYear, &u.BirthMonth,
&u.BackupToken, &u.ChargeMoneyThisMonth)
if err == sql.ErrNoRows {
return u, store.ErrNotFound
}
if err != nil {
return u, fmt.Errorf("load users: %w", err)
}
initMaps(&u)
load1to1(s.db, userId, &u)
loadMapTables(s.db, userId, &u)
return u, nil
}
func initMaps(u *store.UserState) {
u.Tutorials = make(map[int32]store.TutorialProgressState)
u.Characters = make(map[int32]store.CharacterState)
u.Costumes = make(map[string]store.CostumeState)
u.Weapons = make(map[string]store.WeaponState)
u.Companions = make(map[string]store.CompanionState)
u.Thoughts = make(map[string]store.ThoughtState)
u.DeckCharacters = make(map[string]store.DeckCharacterState)
u.Decks = make(map[store.DeckKey]store.DeckState)
u.DeckSubWeapons = make(map[string][]string)
u.DeckParts = make(map[string][]string)
u.Quests = make(map[int32]store.UserQuestState)
u.QuestMissions = make(map[store.QuestMissionKey]store.UserQuestMissionState)
u.Missions = make(map[int32]store.UserMissionState)
u.WeaponStories = make(map[int32]store.WeaponStoryState)
u.WeaponNotes = make(map[int32]store.WeaponNoteState)
u.WeaponSkills = make(map[string][]store.WeaponSkillState)
u.WeaponAbilities = make(map[string][]store.WeaponAbilityState)
u.WeaponAwakens = make(map[string]store.WeaponAwakenState)
u.CostumeActiveSkills = make(map[string]store.CostumeActiveSkillState)
u.CostumeAwakenStatusUps = make(map[store.CostumeAwakenStatusKey]store.CostumeAwakenStatusUpState)
u.CostumeLotteryEffects = make(map[store.CostumeLotteryEffectKey]store.CostumeLotteryEffectState)
u.CostumeLotteryEffectPending = make(map[string]store.CostumeLotteryEffectPendingState)
u.Parts = make(map[string]store.PartsState)
u.PartsGroupNotes = make(map[int32]store.PartsGroupNoteState)
u.PartsPresets = make(map[int32]store.PartsPresetState)
u.DeckTypeNotes = make(map[model.DeckType]store.DeckTypeNoteState)
u.ConsumableItems = make(map[int32]int32)
u.Materials = make(map[int32]int32)
u.ImportantItems = make(map[int32]int32)
u.PremiumItems = make(map[int32]int64)
u.NaviCutInPlayed = make(map[int32]bool)
u.ViewedMovies = make(map[int32]int64)
u.ContentsStories = make(map[int32]int64)
u.DrawnOmikuji = make(map[int32]int64)
u.DokanConfirmed = make(map[int32]bool)
u.ShopItems = make(map[int32]store.UserShopItemState)
u.ShopReplaceableLineup = make(map[int32]store.UserShopReplaceableLineupState)
u.ExploreScores = make(map[int32]store.ExploreScoreState)
u.CageOrnamentRewards = make(map[int32]store.CageOrnamentRewardState)
u.CharacterBoards = make(map[int32]store.CharacterBoardState)
u.CharacterBoardAbilities = make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState)
u.CharacterBoardStatusUps = make(map[store.CharacterBoardStatusUpKey]store.CharacterBoardStatusUpState)
u.CharacterRebirths = make(map[int32]store.CharacterRebirthState)
u.AutoSaleSettings = make(map[int32]store.AutoSaleSettingState)
u.SideStoryQuests = make(map[int32]store.SideStoryQuestProgress)
u.QuestLimitContentStatus = make(map[int32]store.QuestLimitContentStatus)
u.BigHuntMaxScores = make(map[int32]store.BigHuntMaxScore)
u.BigHuntStatuses = make(map[int32]store.BigHuntStatus)
u.BigHuntScheduleMaxScores = make(map[store.BigHuntScheduleScoreKey]store.BigHuntScheduleMaxScore)
u.BigHuntWeeklyMaxScores = make(map[store.BigHuntWeeklyScoreKey]store.BigHuntWeeklyMaxScore)
u.BigHuntWeeklyStatuses = make(map[int64]store.BigHuntWeeklyStatus)
u.Gacha.BannerStates = make(map[int32]store.GachaBannerState)
u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession = []store.ConsumableItemState{}
u.Gifts.NotReceived = []store.NotReceivedGiftState{}
u.Gifts.Received = []store.ReceivedGiftState{}
u.Gimmick.Progress = make(map[store.GimmickKey]store.GimmickProgressState)
u.Gimmick.OrnamentProgress = make(map[store.GimmickOrnamentKey]store.GimmickOrnamentProgressState)
u.Gimmick.Sequences = make(map[store.GimmickSequenceKey]store.GimmickSequenceState)
u.Gimmick.Unlocks = make(map[store.GimmickKey]store.GimmickUnlockState)
}
func load1to1(db *sql.DB, uid int64, u *store.UserState) {
var b int
_ = db.QueryRow(`SELECT is_notify_purchase_alert, latest_version FROM user_setting WHERE user_id=?`, uid).
Scan(&b, &u.Setting.LatestVersion)
u.Setting.IsNotifyPurchaseAlert = b != 0
_ = db.QueryRow(`SELECT level, exp, stamina_milli_value, stamina_update_datetime, latest_version FROM user_status WHERE user_id=?`, uid).
Scan(&u.Status.Level, &u.Status.Exp, &u.Status.StaminaMilliValue, &u.Status.StaminaUpdateDatetime, &u.Status.LatestVersion)
_ = db.QueryRow(`SELECT paid_gem, free_gem FROM user_gem WHERE user_id=?`, uid).
Scan(&u.Gem.PaidGem, &u.Gem.FreeGem)
_ = db.QueryRow(`SELECT name, name_update_datetime, message, message_update_datetime, favorite_costume_id,
favorite_costume_id_update_datetime, latest_version FROM user_profile WHERE user_id=?`, uid).
Scan(&u.Profile.Name, &u.Profile.NameUpdateDatetime, &u.Profile.Message, &u.Profile.MessageUpdateDatetime,
&u.Profile.FavoriteCostumeId, &u.Profile.FavoriteCostumeIdUpdateDatetime, &u.Profile.LatestVersion)
_ = db.QueryRow(`SELECT total_login_count, continual_login_count, max_continual_login_count,
last_login_datetime, last_comeback_login_datetime, latest_version FROM user_login WHERE user_id=?`, uid).
Scan(&u.Login.TotalLoginCount, &u.Login.ContinualLoginCount, &u.Login.MaxContinualLoginCount,
&u.Login.LastLoginDatetime, &u.Login.LastComebackLoginDatetime, &u.Login.LatestVersion)
_ = db.QueryRow(`SELECT login_bonus_id, current_page_number, current_stamp_number,
latest_reward_receive_datetime, latest_version FROM user_login_bonus WHERE user_id=?`, uid).
Scan(&u.LoginBonus.LoginBonusId, &u.LoginBonus.CurrentPageNumber, &u.LoginBonus.CurrentStampNumber,
&u.LoginBonus.LatestRewardReceiveDatetime, &u.LoginBonus.LatestVersion)
_ = db.QueryRow(`SELECT current_quest_flow_type, current_main_quest_route_id, current_quest_scene_id,
head_quest_scene_id, is_reached_last_quest_scene, progress_quest_scene_id, progress_head_quest_scene_id,
progress_quest_flow_type, main_quest_season_id, latest_version, saved_current_quest_scene_id,
saved_head_quest_scene_id, replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id
FROM user_main_quest WHERE user_id=?`, uid).
Scan(&u.MainQuest.CurrentQuestFlowType, &u.MainQuest.CurrentMainQuestRouteId, &u.MainQuest.CurrentQuestSceneId,
&u.MainQuest.HeadQuestSceneId, &b, &u.MainQuest.ProgressQuestSceneId, &u.MainQuest.ProgressHeadQuestSceneId,
&u.MainQuest.ProgressQuestFlowType, &u.MainQuest.MainQuestSeasonId, &u.MainQuest.LatestVersion,
&u.MainQuest.SavedCurrentQuestSceneId, &u.MainQuest.SavedHeadQuestSceneId,
&u.MainQuest.ReplayFlowCurrentQuestSceneId, &u.MainQuest.ReplayFlowHeadQuestSceneId)
u.MainQuest.IsReachedLastQuestScene = b != 0
_ = db.QueryRow(`SELECT current_event_quest_chapter_id, current_quest_id, current_quest_scene_id,
head_quest_scene_id, latest_version FROM user_event_quest WHERE user_id=?`, uid).
Scan(&u.EventQuest.CurrentEventQuestChapterId, &u.EventQuest.CurrentQuestId,
&u.EventQuest.CurrentQuestSceneId, &u.EventQuest.HeadQuestSceneId, &u.EventQuest.LatestVersion)
_ = db.QueryRow(`SELECT current_quest_id, current_quest_scene_id, head_quest_scene_id, latest_version
FROM user_extra_quest WHERE user_id=?`, uid).
Scan(&u.ExtraQuest.CurrentQuestId, &u.ExtraQuest.CurrentQuestSceneId,
&u.ExtraQuest.HeadQuestSceneId, &u.ExtraQuest.LatestVersion)
_ = db.QueryRow(`SELECT current_side_story_quest_id, current_side_story_quest_scene_id, latest_version
FROM user_side_story_active WHERE user_id=?`, uid).
Scan(&u.SideStoryActiveProgress.CurrentSideStoryQuestId,
&u.SideStoryActiveProgress.CurrentSideStoryQuestSceneId, &u.SideStoryActiveProgress.LatestVersion)
var isDryRun int
_ = db.QueryRow(`SELECT current_big_hunt_boss_quest_id, current_big_hunt_quest_id, current_quest_scene_id,
is_dry_run, latest_version, deck_type, user_triple_deck_number, boss_knock_down_count,
max_combo_count, total_damage, deck_number, battle_binary
FROM user_big_hunt_state WHERE user_id=?`, uid).
Scan(&u.BigHuntProgress.CurrentBigHuntBossQuestId, &u.BigHuntProgress.CurrentBigHuntQuestId,
&u.BigHuntProgress.CurrentQuestSceneId, &isDryRun, &u.BigHuntProgress.LatestVersion,
&u.BigHuntBattleDetail.DeckType, &u.BigHuntBattleDetail.UserTripleDeckNumber,
&u.BigHuntBattleDetail.BossKnockDownCount, &u.BigHuntBattleDetail.MaxComboCount,
&u.BigHuntBattleDetail.TotalDamage, &u.BigHuntDeckNumber, &u.BigHuntBattleBinary)
u.BigHuntProgress.IsDryRun = isDryRun != 0
var isActive, isUnread int
_ = db.QueryRow(`SELECT is_active, start_count, finish_count, last_started_at, last_finished_at,
last_user_party_count, last_npc_party_count, last_battle_binary_size, last_elapsed_frame_count
FROM user_battle WHERE user_id=?`, uid).
Scan(&isActive, &u.Battle.StartCount, &u.Battle.FinishCount, &u.Battle.LastStartedAt,
&u.Battle.LastFinishedAt, &u.Battle.LastUserPartyCount, &u.Battle.LastNpcPartyCount,
&u.Battle.LastBattleBinarySize, &u.Battle.LastElapsedFrameCount)
u.Battle.IsActive = isActive != 0
_ = db.QueryRow(`SELECT gift_not_receive_count, friend_request_receive_count, is_exist_unread_information
FROM user_notification WHERE user_id=?`, uid).
Scan(&u.Notifications.GiftNotReceiveCount, &u.Notifications.FriendRequestReceiveCount, &isUnread)
u.Notifications.IsExistUnreadInformation = isUnread != 0
var isCP int
_ = db.QueryRow(`SELECT is_current_progress, drop_item_start_datetime, current_drop_item_count, latest_version
FROM user_portal_cage WHERE user_id=?`, uid).
Scan(&isCP, &u.PortalCageStatus.DropItemStartDatetime, &u.PortalCageStatus.CurrentDropItemCount,
&u.PortalCageStatus.LatestVersion)
u.PortalCageStatus.IsCurrentProgress = isCP != 0
_ = db.QueryRow(`SELECT start_datetime, open_minutes, daily_opened_count, latest_version
FROM user_guerrilla_free_open WHERE user_id=?`, uid).
Scan(&u.GuerrillaFreeOpen.StartDatetime, &u.GuerrillaFreeOpen.OpenMinutes,
&u.GuerrillaFreeOpen.DailyOpenedCount, &u.GuerrillaFreeOpen.LatestVersion)
var isTicket int
_ = db.QueryRow(`SELECT is_use_explore_ticket, playing_explore_id, latest_play_datetime, latest_version
FROM user_explore WHERE user_id=?`, uid).
Scan(&isTicket, &u.Explore.PlayingExploreId, &u.Explore.LatestPlayDatetime, &u.Explore.LatestVersion)
u.Explore.IsUseExploreTicket = isTicket != 0
_ = db.QueryRow(`SELECT lineup_update_count, latest_lineup_update_datetime, latest_version
FROM user_shop_replaceable WHERE user_id=?`, uid).
Scan(&u.ShopReplaceable.LineupUpdateCount, &u.ShopReplaceable.LatestLineupUpdateDatetime,
&u.ShopReplaceable.LatestVersion)
var rewardAvail int
var obtainItemId, obtainCount sql.NullInt64
_ = db.QueryRow(`SELECT reward_available, todays_current_draw_count, daily_max_count,
last_reward_draw_date, obtain_consumable_item_id, obtain_count
FROM user_gacha WHERE user_id=?`, uid).
Scan(&rewardAvail, &u.Gacha.TodaysCurrentDrawCount, &u.Gacha.DailyMaxCount,
&u.Gacha.LastRewardDrawDate, &obtainItemId, &obtainCount)
u.Gacha.RewardAvailable = rewardAvail != 0
if obtainItemId.Valid {
u.Gacha.ConvertedGachaMedal.ObtainPossession = &store.ConsumableItemState{
ConsumableItemId: int32(obtainItemId.Int64),
Count: int32(obtainCount.Int64),
}
}
}
func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
queryRows(db, `SELECT character_id, level, exp, latest_version FROM user_characters WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.CharacterState
rows.Scan(&v.CharacterId, &v.Level, &v.Exp, &v.LatestVersion)
u.Characters[v.CharacterId] = v
})
queryRows(db, `SELECT user_costume_uuid, costume_id, limit_break_count, level, exp,
headup_display_view_id, acquisition_datetime, awaken_count,
costume_lottery_effect_unlocked_slot_count, latest_version
FROM user_costumes WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.CostumeState
rows.Scan(&v.UserCostumeUuid, &v.CostumeId, &v.LimitBreakCount, &v.Level, &v.Exp,
&v.HeadupDisplayViewId, &v.AcquisitionDatetime, &v.AwakenCount,
&v.CostumeLotteryEffectUnlockedSlotCount, &v.LatestVersion)
u.Costumes[v.UserCostumeUuid] = v
})
queryRows(db, `SELECT user_weapon_uuid, weapon_id, level, exp, limit_break_count,
is_protected, acquisition_datetime, latest_version FROM user_weapons WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.WeaponState
var prot int
rows.Scan(&v.UserWeaponUuid, &v.WeaponId, &v.Level, &v.Exp, &v.LimitBreakCount,
&prot, &v.AcquisitionDatetime, &v.LatestVersion)
v.IsProtected = prot != 0
u.Weapons[v.UserWeaponUuid] = v
})
queryRows(db, `SELECT user_companion_uuid, companion_id, headup_display_view_id, level,
acquisition_datetime, latest_version FROM user_companions WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.CompanionState
rows.Scan(&v.UserCompanionUuid, &v.CompanionId, &v.HeadupDisplayViewId, &v.Level,
&v.AcquisitionDatetime, &v.LatestVersion)
u.Companions[v.UserCompanionUuid] = v
})
queryRows(db, `SELECT user_thought_uuid, thought_id, acquisition_datetime, latest_version
FROM user_thoughts WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.ThoughtState
rows.Scan(&v.UserThoughtUuid, &v.ThoughtId, &v.AcquisitionDatetime, &v.LatestVersion)
u.Thoughts[v.UserThoughtUuid] = v
})
queryRows(db, `SELECT user_deck_character_uuid, user_costume_uuid, main_user_weapon_uuid,
user_companion_uuid, power, user_thought_uuid, dressup_costume_id, latest_version
FROM user_deck_characters WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.DeckCharacterState
rows.Scan(&v.UserDeckCharacterUuid, &v.UserCostumeUuid, &v.MainUserWeaponUuid,
&v.UserCompanionUuid, &v.Power, &v.UserThoughtUuid, &v.DressupCostumeId, &v.LatestVersion)
u.DeckCharacters[v.UserDeckCharacterUuid] = v
})
queryRows(db, `SELECT deck_type, user_deck_number, user_deck_character_uuid01, user_deck_character_uuid02,
user_deck_character_uuid03, name, power, latest_version FROM user_decks WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.DeckState
var dt int32
rows.Scan(&dt, &v.UserDeckNumber, &v.UserDeckCharacterUuid01, &v.UserDeckCharacterUuid02,
&v.UserDeckCharacterUuid03, &v.Name, &v.Power, &v.LatestVersion)
v.DeckType = model.DeckType(dt)
u.Decks[store.DeckKey{DeckType: v.DeckType, UserDeckNumber: v.UserDeckNumber}] = v
})
queryRows(db, `SELECT user_deck_character_uuid, ordinal, user_weapon_uuid
FROM user_deck_sub_weapons WHERE user_id=? ORDER BY user_deck_character_uuid, ordinal`, uid,
func(rows *sql.Rows) {
var key, val string
var ord int
rows.Scan(&key, &ord, &val)
u.DeckSubWeapons[key] = append(u.DeckSubWeapons[key], val)
})
queryRows(db, `SELECT user_deck_character_uuid, ordinal, user_parts_uuid
FROM user_deck_parts WHERE user_id=? ORDER BY user_deck_character_uuid, ordinal`, uid,
func(rows *sql.Rows) {
var key, val string
var ord int
rows.Scan(&key, &ord, &val)
u.DeckParts[key] = append(u.DeckParts[key], val)
})
queryRows(db, `SELECT quest_id, quest_state_type, is_battle_only, user_deck_number, latest_start_datetime,
clear_count, daily_clear_count, last_clear_datetime, shortest_clear_frames, is_reward_granted, latest_version
FROM user_quests WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.UserQuestState
var bo, rg int
rows.Scan(&v.QuestId, &v.QuestStateType, &bo, &v.UserDeckNumber, &v.LatestStartDatetime,
&v.ClearCount, &v.DailyClearCount, &v.LastClearDatetime, &v.ShortestClearFrames, &rg, &v.LatestVersion)
v.IsBattleOnly = bo != 0
v.IsRewardGranted = rg != 0
u.Quests[v.QuestId] = v
})
queryRows(db, `SELECT quest_id, quest_mission_id, progress_value, is_clear, latest_clear_datetime, latest_version
FROM user_quest_missions WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.UserQuestMissionState
var ic int
rows.Scan(&v.QuestId, &v.QuestMissionId, &v.ProgressValue, &ic, &v.LatestClearDatetime, &v.LatestVersion)
v.IsClear = ic != 0
u.QuestMissions[store.QuestMissionKey{QuestId: v.QuestId, QuestMissionId: v.QuestMissionId}] = v
})
queryRows(db, `SELECT mission_id, start_datetime, progress_value, mission_progress_status_type,
clear_datetime, latest_version FROM user_missions WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.UserMissionState
rows.Scan(&v.MissionId, &v.StartDatetime, &v.ProgressValue, &v.MissionProgressStatusType,
&v.ClearDatetime, &v.LatestVersion)
u.Missions[v.MissionId] = v
})
queryRows(db, `SELECT tutorial_type, progress_phase, choice_id, latest_version
FROM user_tutorials WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.TutorialProgressState
rows.Scan(&v.TutorialType, &v.ProgressPhase, &v.ChoiceId, &v.LatestVersion)
u.Tutorials[v.TutorialType] = v
})
queryRows(db, `SELECT side_story_quest_id, head_side_story_quest_scene_id, side_story_quest_state_type, latest_version
FROM user_side_story_quests WHERE user_id=?`, uid, func(rows *sql.Rows) {
var id, head, st int32
var lv int64
rows.Scan(&id, &head, &st, &lv)
u.SideStoryQuests[id] = store.SideStoryQuestProgress{
HeadSideStoryQuestSceneId: head, SideStoryQuestStateType: model.SideStoryQuestStateType(st), LatestVersion: lv,
}
})
queryRows(db, `SELECT limit_content_id, limit_content_quest_status_type, event_quest_chapter_id, latest_version
FROM user_quest_limit_content_status WHERE user_id=?`, uid, func(rows *sql.Rows) {
var id int32
var v store.QuestLimitContentStatus
rows.Scan(&id, &v.LimitContentQuestStatusType, &v.EventQuestChapterId, &v.LatestVersion)
u.QuestLimitContentStatus[id] = v
})
queryRows(db, `SELECT weapon_id, released_max_story_index, latest_version FROM user_weapon_stories WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.WeaponStoryState
rows.Scan(&v.WeaponId, &v.ReleasedMaxStoryIndex, &v.LatestVersion)
u.WeaponStories[v.WeaponId] = v
})
queryRows(db, `SELECT weapon_id, max_level, max_limit_break_count, first_acquisition_datetime, latest_version
FROM user_weapon_notes WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.WeaponNoteState
rows.Scan(&v.WeaponId, &v.MaxLevel, &v.MaxLimitBreakCount, &v.FirstAcquisitionDatetime, &v.LatestVersion)
u.WeaponNotes[v.WeaponId] = v
})
queryRows(db, `SELECT user_weapon_uuid, slot_number, level FROM user_weapon_skills WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.WeaponSkillState
rows.Scan(&v.UserWeaponUuid, &v.SlotNumber, &v.Level)
u.WeaponSkills[v.UserWeaponUuid] = append(u.WeaponSkills[v.UserWeaponUuid], v)
})
queryRows(db, `SELECT user_weapon_uuid, slot_number, level FROM user_weapon_abilities WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.WeaponAbilityState
rows.Scan(&v.UserWeaponUuid, &v.SlotNumber, &v.Level)
u.WeaponAbilities[v.UserWeaponUuid] = append(u.WeaponAbilities[v.UserWeaponUuid], v)
})
queryRows(db, `SELECT user_weapon_uuid, latest_version FROM user_weapon_awakens WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.WeaponAwakenState
rows.Scan(&v.UserWeaponUuid, &v.LatestVersion)
u.WeaponAwakens[v.UserWeaponUuid] = v
})
queryRows(db, `SELECT user_costume_uuid, level, acquisition_datetime, latest_version
FROM user_costume_active_skills WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.CostumeActiveSkillState
rows.Scan(&v.UserCostumeUuid, &v.Level, &v.AcquisitionDatetime, &v.LatestVersion)
u.CostumeActiveSkills[v.UserCostumeUuid] = v
})
queryRows(db, `SELECT user_costume_uuid, status_calculation_type, hp, attack, vitality, agility,
critical_ratio, critical_attack, latest_version FROM user_costume_awaken_status_ups WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.CostumeAwakenStatusUpState
var sct int32
rows.Scan(&v.UserCostumeUuid, &sct, &v.Hp, &v.Attack, &v.Vitality, &v.Agility,
&v.CriticalRatio, &v.CriticalAttack, &v.LatestVersion)
v.StatusCalculationType = model.StatusCalculationType(sct)
u.CostumeAwakenStatusUps[store.CostumeAwakenStatusKey{
UserCostumeUuid: v.UserCostumeUuid, StatusCalculationType: v.StatusCalculationType,
}] = v
})
queryRows(db, `SELECT user_costume_uuid, slot_number, odds_number, latest_version
FROM user_costume_lottery_effects WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.CostumeLotteryEffectState
rows.Scan(&v.UserCostumeUuid, &v.SlotNumber, &v.OddsNumber, &v.LatestVersion)
u.CostumeLotteryEffects[store.CostumeLotteryEffectKey{
UserCostumeUuid: v.UserCostumeUuid, SlotNumber: v.SlotNumber,
}] = v
})
queryRows(db, `SELECT user_costume_uuid, slot_number, odds_number, latest_version
FROM user_costume_lottery_effect_pending WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.CostumeLotteryEffectPendingState
rows.Scan(&v.UserCostumeUuid, &v.SlotNumber, &v.OddsNumber, &v.LatestVersion)
u.CostumeLotteryEffectPending[v.UserCostumeUuid] = v
})
queryRows(db, `SELECT user_parts_uuid, parts_id, level, parts_status_main_id, is_protected,
acquisition_datetime, latest_version FROM user_parts WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.PartsState
var prot int
rows.Scan(&v.UserPartsUuid, &v.PartsId, &v.Level, &v.PartsStatusMainId, &prot,
&v.AcquisitionDatetime, &v.LatestVersion)
v.IsProtected = prot != 0
u.Parts[v.UserPartsUuid] = v
})
queryRows(db, `SELECT parts_group_id, first_acquisition_datetime, latest_version
FROM user_parts_group_notes WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.PartsGroupNoteState
rows.Scan(&v.PartsGroupId, &v.FirstAcquisitionDatetime, &v.LatestVersion)
u.PartsGroupNotes[v.PartsGroupId] = v
})
queryRows(db, `SELECT user_parts_preset_number, user_parts_uuid01, user_parts_uuid02, user_parts_uuid03,
name, user_parts_preset_tag_number, latest_version FROM user_parts_presets WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.PartsPresetState
rows.Scan(&v.UserPartsPresetNumber, &v.UserPartsUuid01, &v.UserPartsUuid02, &v.UserPartsUuid03,
&v.Name, &v.UserPartsPresetTagNumber, &v.LatestVersion)
u.PartsPresets[v.UserPartsPresetNumber] = 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
var v store.DeckTypeNoteState
rows.Scan(&dt, &v.MaxDeckPower, &v.LatestVersion)
v.DeckType = model.DeckType(dt)
u.DeckTypeNotes[v.DeckType] = v
})
loadSimpleMap(db, uid, `SELECT consumable_item_id, count FROM user_consumable_items WHERE user_id=?`, u.ConsumableItems)
loadSimpleMap(db, uid, `SELECT material_id, count FROM user_materials WHERE user_id=?`, u.Materials)
loadSimpleMap(db, uid, `SELECT important_item_id, count FROM user_important_items WHERE user_id=?`, u.ImportantItems)
queryRows(db, `SELECT premium_item_id, count FROM user_premium_items WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var k int32
var v int64
rows.Scan(&k, &v)
u.PremiumItems[k] = v
})
queryRows(db, `SELECT explore_id, max_score, max_score_update_datetime, latest_version
FROM user_explore_scores WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.ExploreScoreState
rows.Scan(&v.ExploreId, &v.MaxScore, &v.MaxScoreUpdateDatetime, &v.LatestVersion)
u.ExploreScores[v.ExploreId] = v
})
queryRows(db, `SELECT possession_auto_sale_item_type, possession_auto_sale_item_value
FROM user_auto_sale_settings WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.AutoSaleSettingState
rows.Scan(&v.PossessionAutoSaleItemType, &v.PossessionAutoSaleItemValue)
u.AutoSaleSettings[v.PossessionAutoSaleItemType] = v
})
queryRows(db, `SELECT navi_cutin_id FROM user_navi_cutin_played WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var id int32
rows.Scan(&id)
u.NaviCutInPlayed[id] = true
})
loadTimestampMap(db, uid, `SELECT movie_id, timestamp FROM user_viewed_movies WHERE user_id=?`, u.ViewedMovies)
loadTimestampMap(db, uid, `SELECT contents_story_id, timestamp FROM user_contents_stories WHERE user_id=?`, u.ContentsStories)
loadTimestampMap(db, uid, `SELECT omikuji_id, timestamp FROM user_drawn_omikuji WHERE user_id=?`, u.DrawnOmikuji)
queryRows(db, `SELECT dokan_id FROM user_dokan_confirmed WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var id int32
rows.Scan(&id)
u.DokanConfirmed[id] = true
})
// Gifts
queryRows(db, `SELECT user_gift_uuid, is_received, possession_type, possession_id, count, grant_datetime,
description_gift_text_id, equipment_data, expiration_datetime, received_datetime
FROM user_gifts WHERE user_id=?`, uid, func(rows *sql.Rows) {
var uuid string
var isRecv int
var gc store.GiftCommonState
var expDt, recvDt sql.NullInt64
var equipData []byte
rows.Scan(&uuid, &isRecv, &gc.PossessionType, &gc.PossessionId, &gc.Count, &gc.GrantDatetime,
&gc.DescriptionGiftTextId, &equipData, &expDt, &recvDt)
gc.EquipmentData = equipData
if isRecv == 0 {
u.Gifts.NotReceived = append(u.Gifts.NotReceived, store.NotReceivedGiftState{
GiftCommon: gc, ExpirationDatetime: expDt.Int64, UserGiftUuid: uuid,
})
} else {
u.Gifts.Received = append(u.Gifts.Received, store.ReceivedGiftState{
GiftCommon: gc, ReceivedDatetime: recvDt.Int64,
})
}
})
// Gacha converted medals
queryRows(db, `SELECT consumable_item_id, count FROM user_gacha_converted_medals WHERE user_id=? ORDER BY ordinal`, uid,
func(rows *sql.Rows) {
var v store.ConsumableItemState
rows.Scan(&v.ConsumableItemId, &v.Count)
u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession = append(u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession, v)
})
// Gacha banners
queryRows(db, `SELECT gacha_id, medal_count, step_number, loop_count, draw_count, box_number
FROM user_gacha_banners WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.GachaBannerState
rows.Scan(&v.GachaId, &v.MedalCount, &v.StepNumber, &v.LoopCount, &v.DrawCount, &v.BoxNumber)
v.BoxDrewCounts = make(map[int32]int32)
u.Gacha.BannerStates[v.GachaId] = v
})
queryRows(db, `SELECT gacha_id, box_item_id, count FROM user_gacha_banner_box_drew_counts WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var gachaId, boxItemId, count int32
rows.Scan(&gachaId, &boxItemId, &count)
if bs, ok := u.Gacha.BannerStates[gachaId]; ok {
bs.BoxDrewCounts[boxItemId] = count
u.Gacha.BannerStates[gachaId] = bs
}
})
// Character boards
queryRows(db, `SELECT character_board_id, panel_release_bit1, panel_release_bit2, panel_release_bit3,
panel_release_bit4, latest_version FROM user_character_boards WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.CharacterBoardState
rows.Scan(&v.CharacterBoardId, &v.PanelReleaseBit1, &v.PanelReleaseBit2,
&v.PanelReleaseBit3, &v.PanelReleaseBit4, &v.LatestVersion)
u.CharacterBoards[v.CharacterBoardId] = v
})
queryRows(db, `SELECT character_id, ability_id, level, latest_version
FROM user_character_board_abilities WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.CharacterBoardAbilityState
rows.Scan(&v.CharacterId, &v.AbilityId, &v.Level, &v.LatestVersion)
u.CharacterBoardAbilities[store.CharacterBoardAbilityKey{CharacterId: v.CharacterId, AbilityId: v.AbilityId}] = v
})
queryRows(db, `SELECT character_id, status_calculation_type, hp, attack, vitality, agility,
critical_ratio, critical_attack, latest_version FROM user_character_board_status_ups WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.CharacterBoardStatusUpState
rows.Scan(&v.CharacterId, &v.StatusCalculationType, &v.Hp, &v.Attack, &v.Vitality, &v.Agility,
&v.CriticalRatio, &v.CriticalAttack, &v.LatestVersion)
u.CharacterBoardStatusUps[store.CharacterBoardStatusUpKey{
CharacterId: v.CharacterId, StatusCalculationType: v.StatusCalculationType,
}] = v
})
queryRows(db, `SELECT character_id, rebirth_count, latest_version FROM user_character_rebirths WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.CharacterRebirthState
rows.Scan(&v.CharacterId, &v.RebirthCount, &v.LatestVersion)
u.CharacterRebirths[v.CharacterId] = v
})
queryRows(db, `SELECT cage_ornament_id, acquisition_datetime, latest_version
FROM user_cage_ornament_rewards WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.CageOrnamentRewardState
rows.Scan(&v.CageOrnamentId, &v.AcquisitionDatetime, &v.LatestVersion)
u.CageOrnamentRewards[v.CageOrnamentId] = v
})
queryRows(db, `SELECT shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version
FROM user_shop_items WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.UserShopItemState
rows.Scan(&v.ShopItemId, &v.BoughtCount, &v.LatestBoughtCountChangedDatetime, &v.LatestVersion)
u.ShopItems[v.ShopItemId] = v
})
queryRows(db, `SELECT slot_number, shop_item_id, latest_version FROM user_shop_replaceable_lineup WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.UserShopReplaceableLineupState
rows.Scan(&v.SlotNumber, &v.ShopItemId, &v.LatestVersion)
u.ShopReplaceableLineup[v.SlotNumber] = v
})
// Gimmick tables
queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id,
is_gimmick_cleared, start_datetime, latest_version FROM user_gimmick_progress WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.GimmickProgressState
var ic int
rows.Scan(&v.Key.GimmickSequenceScheduleId, &v.Key.GimmickSequenceId, &v.Key.GimmickId,
&ic, &v.StartDatetime, &v.LatestVersion)
v.IsGimmickCleared = ic != 0
u.Gimmick.Progress[v.Key] = v
})
queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id,
gimmick_ornament_index, progress_value_bit, base_datetime, latest_version
FROM user_gimmick_ornament_progress WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.GimmickOrnamentProgressState
rows.Scan(&v.Key.GimmickSequenceScheduleId, &v.Key.GimmickSequenceId, &v.Key.GimmickId,
&v.Key.GimmickOrnamentIndex, &v.ProgressValueBit, &v.BaseDatetime, &v.LatestVersion)
u.Gimmick.OrnamentProgress[v.Key] = v
})
queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id,
is_gimmick_sequence_cleared, clear_datetime, latest_version FROM user_gimmick_sequences WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.GimmickSequenceState
var ic int
rows.Scan(&v.Key.GimmickSequenceScheduleId, &v.Key.GimmickSequenceId,
&ic, &v.ClearDatetime, &v.LatestVersion)
v.IsGimmickSequenceCleared = ic != 0
u.Gimmick.Sequences[v.Key] = v
})
queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id,
is_unlocked, latest_version FROM user_gimmick_unlocks WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.GimmickUnlockState
var iu int
rows.Scan(&v.Key.GimmickSequenceScheduleId, &v.Key.GimmickSequenceId, &v.Key.GimmickId,
&iu, &v.LatestVersion)
v.IsUnlocked = iu != 0
u.Gimmick.Unlocks[v.Key] = v
})
// Big hunt maps
queryRows(db, `SELECT big_hunt_boss_id, max_score, max_score_update_datetime, latest_version
FROM user_big_hunt_max_scores WHERE user_id=?`, uid, func(rows *sql.Rows) {
var id int32
var v store.BigHuntMaxScore
rows.Scan(&id, &v.MaxScore, &v.MaxScoreUpdateDatetime, &v.LatestVersion)
u.BigHuntMaxScores[id] = v
})
queryRows(db, `SELECT big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, latest_version
FROM user_big_hunt_statuses WHERE user_id=?`, uid, func(rows *sql.Rows) {
var id int32
var v store.BigHuntStatus
rows.Scan(&id, &v.DailyChallengeCount, &v.LatestChallengeDatetime, &v.LatestVersion)
u.BigHuntStatuses[id] = v
})
queryRows(db, `SELECT big_hunt_schedule_id, big_hunt_boss_id, max_score, max_score_update_datetime, latest_version
FROM user_big_hunt_schedule_max_scores WHERE user_id=?`, uid, func(rows *sql.Rows) {
var k store.BigHuntScheduleScoreKey
var v store.BigHuntScheduleMaxScore
rows.Scan(&k.BigHuntScheduleId, &k.BigHuntBossId, &v.MaxScore, &v.MaxScoreUpdateDatetime, &v.LatestVersion)
u.BigHuntScheduleMaxScores[k] = v
})
queryRows(db, `SELECT big_hunt_weekly_version, attribute_type, max_score, latest_version
FROM user_big_hunt_weekly_max_scores WHERE user_id=?`, uid, func(rows *sql.Rows) {
var k store.BigHuntWeeklyScoreKey
var v store.BigHuntWeeklyMaxScore
rows.Scan(&k.BigHuntWeeklyVersion, &k.AttributeType, &v.MaxScore, &v.LatestVersion)
u.BigHuntWeeklyMaxScores[k] = v
})
queryRows(db, `SELECT big_hunt_weekly_version, is_received_weekly_reward, latest_version
FROM user_big_hunt_weekly_statuses WHERE user_id=?`, uid, func(rows *sql.Rows) {
var ver int64
var ir int
var lv int64
rows.Scan(&ver, &ir, &lv)
u.BigHuntWeeklyStatuses[ver] = store.BigHuntWeeklyStatus{IsReceivedWeeklyReward: ir != 0, LatestVersion: lv}
})
}
func queryRows(db *sql.DB, query string, uid int64, scan func(*sql.Rows)) {
rows, err := db.Query(query, uid)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
scan(rows)
}
}
func loadSimpleMap(db *sql.DB, uid int64, query string, m map[int32]int32) {
queryRows(db, query, uid, func(rows *sql.Rows) {
var k, v int32
rows.Scan(&k, &v)
m[k] = v
})
}
func loadTimestampMap(db *sql.DB, uid int64, query string, m map[int32]int64) {
queryRows(db, query, uid, func(rows *sql.Rows) {
var k int32
var v int64
rows.Scan(&k, &v)
m[k] = v
})
}
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
package sqlite
import (
"fmt"
"time"
"lunar-tear/server/internal/store"
)
func (s *SQLiteStore) CreateSession(uuid string, ttl time.Duration) (store.SessionState, error) {
var userId int64
err := s.db.QueryRow(`SELECT user_id FROM users WHERE uuid = ?`, uuid).Scan(&userId)
if err != nil {
return store.SessionState{}, store.ErrNotFound
}
now := s.clock()
sessionKey := fmt.Sprintf("session_%d_%d", userId, now.UnixNano())
expireAt := now.Add(ttl)
_, err = s.db.Exec(
`INSERT INTO sessions (session_key, user_id, uuid, expire_at) VALUES (?, ?, ?, ?)`,
sessionKey, userId, uuid, expireAt.Format(time.RFC3339Nano),
)
if err != nil {
return store.SessionState{}, fmt.Errorf("insert session: %w", err)
}
return store.SessionState{
SessionKey: sessionKey,
UserId: userId,
Uuid: uuid,
ExpireAt: expireAt,
}, nil
}
func (s *SQLiteStore) ResolveUserId(sessionKey string) (int64, error) {
var userId int64
var expireStr string
err := s.db.QueryRow(
`SELECT user_id, expire_at FROM sessions WHERE session_key = ?`, sessionKey,
).Scan(&userId, &expireStr)
if err != nil {
return 0, store.ErrNotFound
}
expireAt, err := time.Parse(time.RFC3339Nano, expireStr)
if err != nil {
return 0, store.ErrNotFound
}
if s.clock().After(expireAt) {
return 0, store.ErrNotFound
}
return userId, nil
}
+25
View File
@@ -0,0 +1,25 @@
package sqlite
import (
"database/sql"
"time"
"lunar-tear/server/internal/store"
)
type SQLiteStore struct {
db *sql.DB
clock store.Clock
}
var (
_ store.UserRepository = (*SQLiteStore)(nil)
_ store.SessionRepository = (*SQLiteStore)(nil)
)
func New(db *sql.DB, clock store.Clock) *SQLiteStore {
if clock == nil {
clock = time.Now
}
return &SQLiteStore{db: db, clock: clock}
}
+218
View File
@@ -0,0 +1,218 @@
package sqlite
import (
"database/sql"
"fmt"
"lunar-tear/server/internal/store"
)
func (s *SQLiteStore) CreateUser(uuid string) (int64, error) {
tx, err := s.db.Begin()
if err != nil {
return 0, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
var existingId int64
err = tx.QueryRow(`SELECT user_id FROM users WHERE uuid = ?`, uuid).Scan(&existingId)
if err == nil {
return existingId, nil
}
nowMillis := s.clock().UnixMilli()
res, err := tx.Exec(`INSERT INTO users (uuid, player_id, os_type, platform_type, user_restriction_type,
register_datetime, game_start_datetime, latest_version, birth_year, birth_month,
backup_token, charge_money_this_month) VALUES (?, 0, 2, 2, 0, ?, ?, 0, 2000, 1, 'mock-backup-token', 0)`,
uuid, nowMillis, nowMillis)
if err != nil {
return 0, fmt.Errorf("insert user: %w", err)
}
userId, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("last insert id: %w", err)
}
// player_id = user_id
if _, err := tx.Exec(`UPDATE users SET player_id = ? WHERE user_id = ?`, userId, userId); err != nil {
return 0, fmt.Errorf("update player_id: %w", err)
}
user := store.SeedUserState(userId, uuid, nowMillis)
if err := writeUserState(tx, userId, user); err != nil {
return 0, fmt.Errorf("write seed state: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit: %w", err)
}
return userId, nil
}
func (s *SQLiteStore) GetUserByUUID(uuid string) (int64, error) {
var userId int64
err := s.db.QueryRow(`SELECT user_id FROM users WHERE uuid = ?`, uuid).Scan(&userId)
if err == sql.ErrNoRows {
return 0, store.ErrNotFound
}
if err != nil {
return 0, fmt.Errorf("query user: %w", err)
}
return userId, nil
}
func (s *SQLiteStore) DefaultUserId() (int64, error) {
var userId int64
err := s.db.QueryRow(`SELECT min(user_id) FROM users`).Scan(&userId)
if err != nil || userId == 0 {
return 0, store.ErrNotFound
}
return userId, nil
}
// ImportUser replaces all data for u.UserId in the database with the
// contents of u. Any pre-existing rows for that user are deleted first.
func (s *SQLiteStore) ImportUser(u *store.UserState) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
uid := u.UserId
// Child tables in reverse-dependency order (matches schema's goose Down).
childTables := []string{
"user_cage_ornament_rewards",
"user_shop_replaceable_lineup",
"user_shop_items",
"user_gacha_banner_box_drew_counts",
"user_gacha_banners",
"user_gacha_converted_medals",
"user_gifts",
"user_dokan_confirmed",
"user_drawn_omikuji",
"user_contents_stories",
"user_viewed_movies",
"user_navi_cutin_played",
"user_auto_sale_settings",
"user_explore_scores",
"user_tutorials",
"user_premium_items",
"user_important_items",
"user_materials",
"user_consumable_items",
"user_gimmick_unlocks",
"user_gimmick_sequences",
"user_gimmick_ornament_progress",
"user_gimmick_progress",
"user_big_hunt_weekly_statuses",
"user_big_hunt_weekly_max_scores",
"user_big_hunt_schedule_max_scores",
"user_big_hunt_statuses",
"user_big_hunt_max_scores",
"user_quest_limit_content_status",
"user_side_story_quests",
"user_missions",
"user_quest_missions",
"user_quests",
"user_deck_type_notes",
"user_deck_parts",
"user_deck_sub_weapons",
"user_decks",
"user_deck_characters",
"user_parts_presets",
"user_parts_group_notes",
"user_parts",
"user_thoughts",
"user_companions",
"user_weapon_notes",
"user_weapon_stories",
"user_weapon_awakens",
"user_weapon_abilities",
"user_weapon_skills",
"user_weapons",
"user_costume_awaken_status_ups",
"user_costume_active_skills",
"user_costumes",
"user_character_rebirths",
"user_character_board_status_ups",
"user_character_board_abilities",
"user_character_boards",
"user_characters",
"user_gacha",
"user_shop_replaceable",
"user_explore",
"user_guerrilla_free_open",
"user_portal_cage",
"user_notification",
"user_battle",
"user_big_hunt_state",
"user_side_story_active",
"user_extra_quest",
"user_event_quest",
"user_main_quest",
"user_login_bonus",
"user_login",
"user_profile",
"user_gem",
"user_status",
"user_setting",
"sessions",
}
for _, t := range childTables {
if _, err := tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, t), uid); err != nil {
return fmt.Errorf("delete from %s: %w", t, err)
}
}
if _, err := tx.Exec(`DELETE FROM users WHERE user_id = ?`, uid); err != nil {
return fmt.Errorf("delete user: %w", err)
}
if _, err := tx.Exec(`INSERT INTO users (user_id, uuid, player_id, os_type, platform_type,
user_restriction_type, register_datetime, game_start_datetime, latest_version,
birth_year, birth_month, backup_token, charge_money_this_month)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
uid, u.Uuid, u.PlayerId, u.OsType, u.PlatformType, u.UserRestrictionType,
u.RegisterDatetime, u.GameStartDatetime, u.LatestVersion,
u.BirthYear, u.BirthMonth, u.BackupToken, u.ChargeMoneyThisMonth); err != nil {
return fmt.Errorf("insert user: %w", err)
}
if err := writeUserState(tx, uid, u); err != nil {
return fmt.Errorf("write user state: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
func (s *SQLiteStore) UpdateUser(userId int64, mutate func(*store.UserState)) (store.UserState, error) {
before, err := s.LoadUser(userId)
if err != nil {
return store.UserState{}, err
}
after := store.CloneUserState(before)
mutate(&after)
tx, err := s.db.Begin()
if err != nil {
return store.UserState{}, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if err := diffAndSave(tx, userId, &before, &after); err != nil {
return store.UserState{}, fmt.Errorf("diff and save: %w", err)
}
if err := tx.Commit(); err != nil {
return store.UserState{}, fmt.Errorf("commit: %w", err)
}
return after, nil
}
+4 -8
View File
@@ -10,18 +10,14 @@ var ErrNotFound = errors.New("store: not found")
type Clock func() time.Time
type UserRepository interface {
EnsureUser(uuid string) (UserState, error)
SnapshotUser(userId int64) (UserState, error)
CreateUser(uuid string) (int64, error)
GetUserByUUID(uuid string) (int64, error)
LoadUser(userId int64) (UserState, error)
UpdateUser(userId int64, mutate func(*UserState)) (UserState, error)
DefaultUserId() (int64, error)
}
type SessionRepository interface {
CreateSession(uuid string, ttl time.Duration) (UserState, SessionState, error)
CreateSession(uuid string, ttl time.Duration) (SessionState, error)
ResolveUserId(sessionKey string) (int64, error)
}
type GachaRepository interface {
SnapshotCatalog() ([]GachaCatalogEntry, error)
ReplaceCatalog(entries []GachaCatalogEntry) error
}
+59 -12
View File
@@ -107,9 +107,11 @@ type UserState struct {
CharacterBoardAbilities map[CharacterBoardAbilityKey]CharacterBoardAbilityState
CharacterBoardStatusUps map[CharacterBoardStatusUpKey]CharacterBoardStatusUpState
CostumeAwakenStatusUps map[CostumeAwakenStatusKey]CostumeAwakenStatusUpState
AutoSaleSettings map[int32]AutoSaleSettingState
CharacterRebirths map[int32]CharacterRebirthState
CostumeAwakenStatusUps map[CostumeAwakenStatusKey]CostumeAwakenStatusUpState
CostumeLotteryEffects map[CostumeLotteryEffectKey]CostumeLotteryEffectState
CostumeLotteryEffectPending map[string]CostumeLotteryEffectPendingState // key: userCostumeUuid
AutoSaleSettings map[int32]AutoSaleSettingState
CharacterRebirths map[int32]CharacterRebirthState
}
func (u *UserState) EnsureMaps() {
@@ -254,6 +256,12 @@ func (u *UserState) EnsureMaps() {
if u.CostumeAwakenStatusUps == nil {
u.CostumeAwakenStatusUps = make(map[CostumeAwakenStatusKey]CostumeAwakenStatusUpState)
}
if u.CostumeLotteryEffects == nil {
u.CostumeLotteryEffects = make(map[CostumeLotteryEffectKey]CostumeLotteryEffectState)
}
if u.CostumeLotteryEffectPending == nil {
u.CostumeLotteryEffectPending = make(map[string]CostumeLotteryEffectPendingState)
}
if u.AutoSaleSettings == nil {
u.AutoSaleSettings = make(map[int32]AutoSaleSettingState)
}
@@ -358,15 +366,16 @@ type CharacterState struct {
}
type CostumeState struct {
UserCostumeUuid string
CostumeId int32
LimitBreakCount int32
Level int32
Exp int32
HeadupDisplayViewId int32
AcquisitionDatetime int64
AwakenCount int32
LatestVersion int64
UserCostumeUuid string
CostumeId int32
LimitBreakCount int32
Level int32
Exp int32
HeadupDisplayViewId int32
AcquisitionDatetime int64
AwakenCount int32
CostumeLotteryEffectUnlockedSlotCount int32
LatestVersion int64
}
type WeaponState struct {
@@ -1070,3 +1079,41 @@ type CharacterRebirthState struct {
RebirthCount int32
LatestVersion int64
}
type CostumeLotteryEffectKey struct {
UserCostumeUuid string
SlotNumber int32
}
func (k CostumeLotteryEffectKey) MarshalText() ([]byte, error) {
return fmt.Appendf(nil, "%s:%d", k.UserCostumeUuid, k.SlotNumber), nil
}
func (k *CostumeLotteryEffectKey) UnmarshalText(text []byte) error {
s := string(text)
idx := strings.LastIndex(s, ":")
if idx < 0 {
return fmt.Errorf("invalid CostumeLotteryEffectKey: %s", text)
}
k.UserCostumeUuid = s[:idx]
v, err := strconv.ParseInt(s[idx+1:], 10, 32)
if err != nil {
return err
}
k.SlotNumber = int32(v)
return nil
}
type CostumeLotteryEffectState struct {
UserCostumeUuid string
SlotNumber int32
OddsNumber int32
LatestVersion int64
}
type CostumeLotteryEffectPendingState struct {
UserCostumeUuid string
SlotNumber int32
OddsNumber int32
LatestVersion int64
}