mirror of
https://github.com/Walter-Sparrow/lunar-tear.git
synced 2026-07-02 05:43:41 +03:00
Initial commit
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
"lunar-tear/server/internal/gametime"
|
||||
"lunar-tear/server/internal/model"
|
||||
)
|
||||
|
||||
func DeductPrice(user *UserState, priceType, priceId, amount int32) error {
|
||||
switch priceType {
|
||||
case model.PriceTypeConsumableItem:
|
||||
cur := user.ConsumableItems[priceId]
|
||||
if cur < amount {
|
||||
return fmt.Errorf("insufficient consumable %d: have %d, need %d", priceId, cur, amount)
|
||||
}
|
||||
user.ConsumableItems[priceId] = cur - amount
|
||||
case model.PriceTypeGem:
|
||||
total := user.Gem.FreeGem + user.Gem.PaidGem
|
||||
if total < amount {
|
||||
return fmt.Errorf("insufficient gems: have %d, need %d", total, amount)
|
||||
}
|
||||
if user.Gem.FreeGem >= amount {
|
||||
user.Gem.FreeGem -= amount
|
||||
} else {
|
||||
amount -= user.Gem.FreeGem
|
||||
user.Gem.FreeGem = 0
|
||||
user.Gem.PaidGem -= amount
|
||||
}
|
||||
case model.PriceTypePaidGem:
|
||||
if user.Gem.PaidGem < amount {
|
||||
return fmt.Errorf("insufficient paid gems: have %d, need %d", user.Gem.PaidGem, amount)
|
||||
}
|
||||
user.Gem.PaidGem -= amount
|
||||
case model.PriceTypePlatformPayment:
|
||||
// real-money purchase -- treat as free on private server
|
||||
default:
|
||||
log.Printf("[DeductPrice] unhandled priceType=%d priceId=%d amount=%d", priceType, priceId, amount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeductPossession(user *UserState, possessionType model.PossessionType, possessionId, count int32) {
|
||||
switch possessionType {
|
||||
case model.PossessionTypeMaterial:
|
||||
user.Materials[possessionId] -= count
|
||||
if user.Materials[possessionId] <= 0 {
|
||||
delete(user.Materials, possessionId)
|
||||
}
|
||||
case model.PossessionTypeConsumableItem:
|
||||
user.ConsumableItems[possessionId] -= count
|
||||
if user.ConsumableItems[possessionId] <= 0 {
|
||||
delete(user.ConsumableItems, possessionId)
|
||||
}
|
||||
case model.PossessionTypePaidGem:
|
||||
user.Gem.PaidGem -= count
|
||||
case model.PossessionTypeFreeGem:
|
||||
user.Gem.FreeGem -= count
|
||||
default:
|
||||
log.Printf("[DeductPossession] unhandled type=%d id=%d count=%d", possessionType, possessionId, count)
|
||||
}
|
||||
}
|
||||
|
||||
func GrantPossession(user *UserState, possessionType model.PossessionType, possessionId, count int32) {
|
||||
switch possessionType {
|
||||
case model.PossessionTypeMaterial:
|
||||
user.Materials[possessionId] += count
|
||||
case model.PossessionTypeConsumableItem:
|
||||
user.ConsumableItems[possessionId] += count
|
||||
case model.PossessionTypePaidGem:
|
||||
user.Gem.PaidGem += count
|
||||
case model.PossessionTypeFreeGem:
|
||||
user.Gem.FreeGem += count
|
||||
case model.PossessionTypeImportantItem:
|
||||
user.ImportantItems[possessionId] += count
|
||||
case model.PossessionTypePremiumItem:
|
||||
user.PremiumItems[possessionId] = gametime.NowMillis()
|
||||
default:
|
||||
log.Printf("[GrantPossession] unhandled type=%d id=%d count=%d", possessionType, possessionId, count)
|
||||
}
|
||||
}
|
||||
|
||||
type CostumeRef struct {
|
||||
CharacterId int32
|
||||
}
|
||||
|
||||
type WeaponRef struct {
|
||||
WeaponSkillGroupId int32
|
||||
WeaponAbilityGroupId int32
|
||||
WeaponStoryReleaseConditionGroupId int32
|
||||
}
|
||||
|
||||
type WeaponStoryReleaseCond struct {
|
||||
StoryIndex int32
|
||||
WeaponStoryReleaseConditionType model.WeaponStoryReleaseConditionType
|
||||
ConditionValue int32
|
||||
}
|
||||
|
||||
type PossessionGranter struct {
|
||||
CostumeById map[int32]CostumeRef
|
||||
WeaponById map[int32]WeaponRef
|
||||
WeaponSkillSlots map[int32][]int32
|
||||
WeaponAbilitySlots map[int32][]int32
|
||||
ReleaseConditions map[int32][]WeaponStoryReleaseCond
|
||||
}
|
||||
|
||||
func (g *PossessionGranter) GrantFull(user *UserState, possessionType model.PossessionType, possessionId, count int32, nowMillis int64) {
|
||||
switch possessionType {
|
||||
case model.PossessionTypeCostume, model.PossessionTypeCostumeEnhanced:
|
||||
g.GrantCostume(user, possessionId, nowMillis)
|
||||
case model.PossessionTypeWeapon, model.PossessionTypeWeaponEnhanced:
|
||||
g.GrantWeapon(user, possessionId, nowMillis)
|
||||
default:
|
||||
GrantPossession(user, possessionType, possessionId, count)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *PossessionGranter) GrantCostume(user *UserState, costumeId int32, nowMillis int64) {
|
||||
for _, row := range user.Costumes {
|
||||
if row.CostumeId == costumeId {
|
||||
return
|
||||
}
|
||||
}
|
||||
if cm, ok := g.CostumeById[costumeId]; ok {
|
||||
if _, exists := user.Characters[cm.CharacterId]; !exists {
|
||||
user.Characters[cm.CharacterId] = CharacterState{
|
||||
CharacterId: cm.CharacterId,
|
||||
Level: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
key := fmt.Sprintf("reward-costume-%d", costumeId)
|
||||
user.Costumes[key] = CostumeState{
|
||||
UserCostumeUuid: key,
|
||||
CostumeId: costumeId,
|
||||
Level: 1,
|
||||
HeadupDisplayViewId: 1,
|
||||
AcquisitionDatetime: nowMillis,
|
||||
}
|
||||
user.CostumeActiveSkills[key] = CostumeActiveSkillState{
|
||||
UserCostumeUuid: key,
|
||||
Level: 1,
|
||||
AcquisitionDatetime: nowMillis,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *PossessionGranter) GrantWeapon(user *UserState, weaponId int32, nowMillis int64) {
|
||||
key := fmt.Sprintf("reward-weapon-%d-%d", weaponId, nowMillis)
|
||||
user.Weapons[key] = WeaponState{
|
||||
UserWeaponUuid: key,
|
||||
WeaponId: weaponId,
|
||||
Level: 1,
|
||||
AcquisitionDatetime: nowMillis,
|
||||
}
|
||||
if _, exists := user.WeaponNotes[weaponId]; !exists {
|
||||
user.WeaponNotes[weaponId] = WeaponNoteState{
|
||||
WeaponId: weaponId,
|
||||
MaxLevel: 1,
|
||||
MaxLimitBreakCount: 0,
|
||||
FirstAcquisitionDatetime: nowMillis,
|
||||
LatestVersion: nowMillis,
|
||||
}
|
||||
}
|
||||
weapon, ok := g.WeaponById[weaponId]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
g.populateWeaponSkillsAbilities(user, key, weapon)
|
||||
if weapon.WeaponStoryReleaseConditionGroupId != 0 {
|
||||
for _, cond := range g.ReleaseConditions[weapon.WeaponStoryReleaseConditionGroupId] {
|
||||
switch cond.WeaponStoryReleaseConditionType {
|
||||
case model.WeaponStoryReleaseConditionTypeAcquisition:
|
||||
grantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
|
||||
case model.WeaponStoryReleaseConditionTypeQuestClear:
|
||||
if qs, ok := user.Quests[cond.ConditionValue]; ok && qs.QuestStateType == model.UserQuestStateTypeCleared {
|
||||
grantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *PossessionGranter) populateWeaponSkillsAbilities(user *UserState, weaponUuid string, weapon WeaponRef) {
|
||||
if slots, ok := g.WeaponSkillSlots[weapon.WeaponSkillGroupId]; ok {
|
||||
skills := make([]WeaponSkillState, len(slots))
|
||||
for i, slot := range slots {
|
||||
skills[i] = WeaponSkillState{
|
||||
UserWeaponUuid: weaponUuid,
|
||||
SlotNumber: slot,
|
||||
Level: 1,
|
||||
}
|
||||
}
|
||||
user.WeaponSkills[weaponUuid] = skills
|
||||
}
|
||||
if slots, ok := g.WeaponAbilitySlots[weapon.WeaponAbilityGroupId]; ok {
|
||||
abilities := make([]WeaponAbilityState, len(slots))
|
||||
for i, slot := range slots {
|
||||
abilities[i] = WeaponAbilityState{
|
||||
UserWeaponUuid: weaponUuid,
|
||||
SlotNumber: slot,
|
||||
Level: 1,
|
||||
}
|
||||
}
|
||||
user.WeaponAbilities[weaponUuid] = abilities
|
||||
}
|
||||
}
|
||||
|
||||
func GrantWeaponStoryUnlock(user *UserState, weaponId, storyIndex int32, nowMillis int64) {
|
||||
grantWeaponStoryUnlock(user, weaponId, storyIndex, nowMillis)
|
||||
}
|
||||
|
||||
func grantWeaponStoryUnlock(user *UserState, weaponId, storyIndex int32, nowMillis int64) {
|
||||
hasWeapon := false
|
||||
for _, row := range user.Weapons {
|
||||
if row.WeaponId == weaponId {
|
||||
hasWeapon = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWeapon {
|
||||
log.Printf("[grantWeaponStoryUnlock] skipping weaponId=%d (weapon not in user.Weapons)", weaponId)
|
||||
return
|
||||
}
|
||||
if user.WeaponStories == nil {
|
||||
user.WeaponStories = make(map[int32]WeaponStoryState)
|
||||
}
|
||||
cur := user.WeaponStories[weaponId]
|
||||
if storyIndex <= cur.ReleasedMaxStoryIndex {
|
||||
return
|
||||
}
|
||||
user.WeaponStories[weaponId] = WeaponStoryState{
|
||||
WeaponId: weaponId,
|
||||
ReleasedMaxStoryIndex: storyIndex,
|
||||
LatestVersion: nowMillis,
|
||||
}
|
||||
}
|
||||
|
||||
func EnsureDefaultDeck(user *UserState, nowMillis int64) {
|
||||
if len(user.Costumes) == 0 || len(user.Decks) > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
costumeUuid := FirstSortedKey(user.Costumes)
|
||||
weaponUuid := FirstSortedKey(user.Weapons)
|
||||
companionUuid := FirstSortedKey(user.Companions)
|
||||
|
||||
dcUuid := "default-deck-character-0001"
|
||||
user.DeckCharacters[dcUuid] = DeckCharacterState{
|
||||
UserDeckCharacterUuid: dcUuid,
|
||||
UserCostumeUuid: costumeUuid,
|
||||
MainUserWeaponUuid: weaponUuid,
|
||||
UserCompanionUuid: companionUuid,
|
||||
Power: 100,
|
||||
LatestVersion: nowMillis,
|
||||
}
|
||||
user.Decks[DeckKey{DeckType: model.DeckTypeQuest, UserDeckNumber: 1}] = DeckState{
|
||||
DeckType: model.DeckTypeQuest,
|
||||
UserDeckNumber: 1,
|
||||
UserDeckCharacterUuid01: dcUuid,
|
||||
Name: "Deck 1",
|
||||
Power: 100,
|
||||
LatestVersion: nowMillis,
|
||||
}
|
||||
|
||||
if _, exists := user.DeckTypeNotes[model.DeckTypeQuest]; !exists {
|
||||
user.DeckTypeNotes[model.DeckTypeQuest] = DeckTypeNoteState{
|
||||
DeckType: model.DeckTypeQuest,
|
||||
MaxDeckPower: 100,
|
||||
LatestVersion: nowMillis,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FirstSortedKey[V any](m map[string]V) string {
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys[0]
|
||||
}
|
||||
|
||||
func ApplyDeckReplacement(user *UserState, deckType model.DeckType, userDeckNumber int32, slots []DeckCharacterInput, nowMillis int64) {
|
||||
deckKey := DeckKey{DeckType: deckType, UserDeckNumber: userDeckNumber}
|
||||
deck := user.Decks[deckKey]
|
||||
deck.DeckType = deckType
|
||||
deck.UserDeckNumber = userDeckNumber
|
||||
if deck.Name == "" {
|
||||
deck.Name = fmt.Sprintf("Deck %d", userDeckNumber)
|
||||
}
|
||||
if deck.Power == 0 {
|
||||
deck.Power = 100
|
||||
}
|
||||
|
||||
uuids := []*string{&deck.UserDeckCharacterUuid01, &deck.UserDeckCharacterUuid02, &deck.UserDeckCharacterUuid03}
|
||||
for i, uuid := range uuids {
|
||||
if i >= len(slots) || slots[i].UserCostumeUuid == "" {
|
||||
*uuid = ""
|
||||
continue
|
||||
}
|
||||
slot := slots[i]
|
||||
dcUuid := fmt.Sprintf("deck-%d-%d-%d", deckType, userDeckNumber, i+1)
|
||||
dc := user.DeckCharacters[dcUuid]
|
||||
dc.UserDeckCharacterUuid = dcUuid
|
||||
dc.UserCostumeUuid = slot.UserCostumeUuid
|
||||
dc.MainUserWeaponUuid = slot.MainUserWeaponUuid
|
||||
dc.UserCompanionUuid = slot.UserCompanionUuid
|
||||
dc.UserThoughtUuid = slot.UserThoughtUuid
|
||||
dc.DressupCostumeId = slot.DressupCostumeId
|
||||
dc.LatestVersion = nowMillis
|
||||
user.DeckCharacters[dcUuid] = dc
|
||||
user.DeckSubWeapons[dcUuid] = slot.SubWeaponUuids
|
||||
user.DeckParts[dcUuid] = slot.PartsUuids
|
||||
*uuid = dcUuid
|
||||
}
|
||||
|
||||
deck.LatestVersion = nowMillis
|
||||
user.Decks[deckKey] = deck
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func marshalKey(vals ...int64) []byte {
|
||||
b := strconv.AppendInt(nil, vals[0], 10)
|
||||
for _, v := range vals[1:] {
|
||||
b = append(b, ':')
|
||||
b = strconv.AppendInt(b, v, 10)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func unmarshalKey(text []byte, name string, n int) ([]int64, error) {
|
||||
parts := strings.Split(string(text), ":")
|
||||
if len(parts) != n {
|
||||
return nil, fmt.Errorf("invalid %s: %s", name, text)
|
||||
}
|
||||
out := make([]int64, n)
|
||||
for i, p := range parts {
|
||||
v, err := strconv.ParseInt(p, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package store
|
||||
|
||||
import "log"
|
||||
|
||||
const StaminaRecoveryDivisor int64 = 180
|
||||
|
||||
func SettleStamina(user *UserState, maxStaminaMillis int32, nowMillis int64) {
|
||||
stored := int64(user.Status.StaminaMilliValue)
|
||||
maxMilli := int64(maxStaminaMillis)
|
||||
if stored >= maxMilli {
|
||||
return
|
||||
}
|
||||
elapsed := nowMillis - user.Status.StaminaUpdateDatetime
|
||||
if elapsed <= 0 {
|
||||
return
|
||||
}
|
||||
regen := elapsed / StaminaRecoveryDivisor
|
||||
settled := min(stored+regen, maxMilli)
|
||||
user.Status.StaminaMilliValue = int32(settled)
|
||||
user.Status.StaminaUpdateDatetime = nowMillis
|
||||
}
|
||||
|
||||
func ConsumeStamina(user *UserState, costUnits int32, maxStaminaMillis int32, nowMillis int64) {
|
||||
SettleStamina(user, maxStaminaMillis, nowMillis)
|
||||
user.Status.StaminaMilliValue = max(user.Status.StaminaMilliValue-costUnits*1000, 0)
|
||||
user.Status.StaminaUpdateDatetime = nowMillis
|
||||
log.Printf("[ConsumeStamina] cost=%d -> remaining=%d", costUnits, user.Status.StaminaMilliValue)
|
||||
}
|
||||
|
||||
func RecoverStamina(user *UserState, millis int32, maxStaminaMillis int32, nowMillis int64) {
|
||||
SettleStamina(user, maxStaminaMillis, nowMillis)
|
||||
user.Status.StaminaMilliValue += millis
|
||||
user.Status.StaminaUpdateDatetime = nowMillis
|
||||
log.Printf("[RecoverStamina] +%d -> total=%d", millis, user.Status.StaminaMilliValue)
|
||||
}
|
||||
|
||||
func ReplenishStamina(user *UserState, maxStaminaMillis int32, nowMillis int64) {
|
||||
user.Status.StaminaMilliValue = maxStaminaMillis
|
||||
user.Status.StaminaUpdateDatetime = nowMillis
|
||||
log.Printf("[ReplenishStamina] set to %d", maxStaminaMillis)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
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)
|
||||
UpdateUser(userId int64, mutate func(*UserState)) (UserState, error)
|
||||
DefaultUserId() (int64, error)
|
||||
}
|
||||
|
||||
type SessionRepository interface {
|
||||
CreateSession(uuid string, ttl time.Duration) (UserState, SessionState, error)
|
||||
ResolveUserId(sessionKey string) (int64, error)
|
||||
}
|
||||
|
||||
type GachaRepository interface {
|
||||
SnapshotCatalog() ([]GachaCatalogEntry, error)
|
||||
ReplaceCatalog(entries []GachaCatalogEntry) error
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user