Initial commit

This commit is contained in:
Ilya Groshev
2026-04-14 09:28:26 +03:00
commit 02f511f40c
161 changed files with 21541 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
package service
import (
"log"
"net"
"sync"
"time"
)
type revisionTracker struct {
mu sync.RWMutex
activeByClient map[string]string
lastRevision string
}
type assetResolution struct {
ActiveRevision string
ListRevision string
ListSize int64
Candidates []assetCandidate
}
type assetResolver struct{}
func newRevisionTracker() *revisionTracker {
return &revisionTracker{
activeByClient: make(map[string]string),
}
}
func newAssetResolver() *assetResolver {
return &assetResolver{}
}
func normalizeClientAddr(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err == nil && host != "" {
return host
}
return remoteAddr
}
func (t *revisionTracker) Remember(clientAddr, revision string) {
if revision == "" {
return
}
client := normalizeClientAddr(clientAddr)
t.mu.Lock()
if client != "" {
t.activeByClient[client] = revision
}
t.lastRevision = revision
t.mu.Unlock()
log.Printf("[Octo] Active list revision for client=%s set to %s", client, revision)
}
func (t *revisionTracker) Active(clientAddr string) string {
client := normalizeClientAddr(clientAddr)
t.mu.RLock()
revision := t.activeByClient[client]
if revision == "" {
revision = t.lastRevision
}
t.mu.RUnlock()
if revision == "" {
return "0"
}
return revision
}
func (r *assetResolver) Resolve(objectId, assetType, activeRevision string) (assetResolution, bool) {
start := time.Now()
resolution := assetResolution{ActiveRevision: activeRevision}
revision := activeRevision
candidates, listSize, ok := objectIdToFilePathCandidates(revision, assetType, objectId)
if ok && len(candidates) > 0 {
resolution.ListRevision = revision
resolution.ListSize = listSize
resolution.Candidates = candidates
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
log.Printf("[HTTP] Asset resolve slow: object_id=%s type=%s active_revision=%s list_revision=%s elapsed=%s", objectId, assetType, activeRevision, revision, elapsed)
}
return resolution, true
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
log.Printf("[HTTP] Asset resolve miss: object_id=%s type=%s active_revision=%s elapsed=%s", objectId, assetType, activeRevision, elapsed)
}
return resolution, false
}
func (r *assetResolver) Prewarm(activeRevision string) {
if activeRevision == "" {
return
}
_, _ = loadListBinIndex(activeRevision)
_ = loadInfoIndex(activeRevision)
}
+49
View File
@@ -0,0 +1,49 @@
package service
import (
"context"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type BannerServiceServer struct {
pb.UnimplementedBannerServiceServer
gacha store.GachaRepository
}
func NewBannerServiceServer(gacha store.GachaRepository) *BannerServiceServer {
return &BannerServiceServer{gacha: gacha}
}
func (s *BannerServiceServer) GetMamaBanner(ctx context.Context, req *pb.GetMamaBannerRequest) (*pb.GetMamaBannerResponse, error) {
catalog, _ := s.gacha.SnapshotCatalog()
var termLimited []*pb.GachaBanner
var latestChapter *pb.GachaBanner
for _, entry := range catalog {
if entry.GachaLabelType == model.GachaLabelPortalCage || entry.GachaLabelType == model.GachaLabelRecycle {
continue
}
b := &pb.GachaBanner{
GachaLabelType: entry.GachaLabelType,
GachaAssetName: entry.BannerAssetName,
GachaId: entry.GachaId,
}
switch entry.GachaLabelType {
case model.GachaLabelEvent, model.GachaLabelPremium:
termLimited = append(termLimited, b)
case model.GachaLabelChapter:
if latestChapter == nil || entry.GachaId > latestChapter.GachaId {
latestChapter = b
}
}
}
return &pb.GetMamaBannerResponse{
TermLimitedGacha: termLimited,
LatestChapterGacha: latestChapter,
IsExistUnreadPop: false,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
+54
View File
@@ -0,0 +1,54 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type BattleServiceServer struct {
pb.UnimplementedBattleServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewBattleServiceServer(users store.UserRepository, sessions store.SessionRepository) *BattleServiceServer {
return &BattleServiceServer{users: users, sessions: sessions}
}
func (s *BattleServiceServer) StartWave(ctx context.Context, req *pb.StartWaveRequest) (*pb.StartWaveResponse, error) {
log.Printf("[BattleService] StartWave: userParty=%d npcParty=%d", len(req.UserPartyInitialInfoList), len(req.NpcPartyInitialInfoList))
userId := currentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) {
user.Battle.IsActive = true
user.Battle.StartCount++
user.Battle.LastStartedAt = gametime.NowMillis()
user.Battle.LastUserPartyCount = int32(len(req.UserPartyInitialInfoList))
user.Battle.LastNpcPartyCount = int32(len(req.NpcPartyInitialInfoList))
})
return &pb.StartWaveResponse{
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *BattleServiceServer) FinishWave(ctx context.Context, req *pb.FinishWaveRequest) (*pb.FinishWaveResponse, error) {
log.Printf("[BattleService] FinishWave: battleBinary=%d userParty=%d npcParty=%d elapsedFrames=%d",
len(req.BattleBinary), len(req.UserPartyResultInfoList), len(req.NpcPartyResultInfoList), req.ElapsedFrameCount)
userId := currentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) {
user.Battle.IsActive = false
user.Battle.FinishCount++
user.Battle.LastFinishedAt = gametime.NowMillis()
user.Battle.LastUserPartyCount = int32(len(req.UserPartyResultInfoList))
user.Battle.LastNpcPartyCount = int32(len(req.NpcPartyResultInfoList))
user.Battle.LastBattleBinarySize = int32(len(req.BattleBinary))
user.Battle.LastElapsedFrameCount = req.ElapsedFrameCount
})
return &pb.FinishWaveResponse{
DiffUserData: userdata.EmptyDiff(),
}, nil
}
+92
View File
@@ -0,0 +1,92 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type CageOrnamentServiceServer struct {
pb.UnimplementedCageOrnamentServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CageOrnamentCatalog
granter *store.PossessionGranter
}
func NewCageOrnamentServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CageOrnamentCatalog, granter *store.PossessionGranter) *CageOrnamentServiceServer {
return &CageOrnamentServiceServer{users: users, sessions: sessions, catalog: catalog, granter: granter}
}
func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.ReceiveRewardRequest) (*pb.ReceiveRewardResponse, error) {
log.Printf("[CageOrnamentService] ReceiveReward: cageOrnamentId=%d", req.CageOrnamentId)
reward, ok := s.catalog.LookupReward(req.CageOrnamentId)
if !ok {
log.Fatalf("[CageOrnamentService] ReceiveReward: no reward for cageOrnamentId=%d", req.CageOrnamentId)
}
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.CageOrnamentRewards[req.CageOrnamentId] = store.CageOrnamentRewardState{
CageOrnamentId: req.CageOrnamentId,
AcquisitionDatetime: nowMillis,
LatestVersion: nowMillis,
}
s.granter.GrantFull(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis)
})
diff := userdata.BuildDiffFromTables(userdata.SelectTables(
userdata.FullClientTableMap(user),
[]string{
"IUserMaterial", "IUserConsumableItem", "IUserGem",
"IUserCostume", "IUserCostumeActiveSkill", "IUserCharacter",
"IUserWeapon", "IUserWeaponSkill", "IUserWeaponAbility",
"IUserWeaponNote", "IUserWeaponStory",
"IUserCageOrnamentReward",
},
))
return &pb.ReceiveRewardResponse{
CageOrnamentReward: []*pb.CageOrnamentReward{
{
PossessionType: reward.PossessionType,
PossessionId: reward.PossessionId,
Count: reward.Count,
},
},
DiffUserData: diff,
}, nil
}
func (s *CageOrnamentServiceServer) RecordAccess(ctx context.Context, req *pb.RecordAccessRequest) (*pb.RecordAccessResponse, error) {
log.Printf("[CageOrnamentService] RecordAccess: cageOrnamentId=%d", req.CageOrnamentId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
if _, exists := user.CageOrnamentRewards[req.CageOrnamentId]; !exists {
user.CageOrnamentRewards[req.CageOrnamentId] = store.CageOrnamentRewardState{
CageOrnamentId: req.CageOrnamentId,
AcquisitionDatetime: nowMillis,
LatestVersion: nowMillis,
}
}
})
diff := userdata.BuildDiffFromTables(userdata.SelectTables(
userdata.FullClientTableMap(user),
[]string{"IUserCageOrnamentReward"},
))
return &pb.RecordAccessResponse{
DiffUserData: diff,
}, nil
}
+85
View File
@@ -0,0 +1,85 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type CharacterServiceServer struct {
pb.UnimplementedCharacterServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CharacterRebirthCatalog
config *masterdata.GameConfig
}
func NewCharacterServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterRebirthCatalog, config *masterdata.GameConfig) *CharacterServiceServer {
return &CharacterServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
}
func (s *CharacterServiceServer) Rebirth(ctx context.Context, req *pb.RebirthRequest) (*pb.RebirthResponse, error) {
log.Printf("[CharacterService] Rebirth: characterId=%d rebirthCount=%d", req.CharacterId, req.RebirthCount)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
stepGroupId, ok := s.catalog.StepGroupByCharacterId[req.CharacterId]
if !ok {
log.Printf("[CharacterService] Rebirth: no step group for characterId=%d", req.CharacterId)
return &pb.RebirthResponse{}, nil
}
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"})
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
current := user.CharacterRebirths[req.CharacterId]
currentCount := current.RebirthCount
targetCount := currentCount + req.RebirthCount
for count := currentCount; count < targetCount; count++ {
step, ok := s.catalog.StepByGroupAndCount[masterdata.StepKey{GroupId: stepGroupId, BeforeRebirthCount: count}]
if !ok {
log.Printf("[CharacterService] Rebirth: no step row for groupId=%d beforeCount=%d", stepGroupId, count)
return
}
goldId := s.config.ConsumableItemIdForGold
user.ConsumableItems[goldId] = max(user.ConsumableItems[goldId]-s.config.CharacterRebirthConsumeGold, 0)
log.Printf("[CharacterService] Rebirth: consumed gold=%d", s.config.CharacterRebirthConsumeGold)
materials := s.catalog.MaterialsByGroupId[step.CharacterRebirthMaterialGroupId]
for _, mat := range materials {
user.Materials[mat.MaterialId] -= mat.Count
if user.Materials[mat.MaterialId] <= 0 {
delete(user.Materials, mat.MaterialId)
}
log.Printf("[CharacterService] Rebirth: consumed material=%d count=%d", mat.MaterialId, mat.Count)
}
}
log.Printf("[CharacterService] Rebirth: characterId=%d count %d -> %d", req.CharacterId, currentCount, targetCount)
user.CharacterRebirths[req.CharacterId] = store.CharacterRebirthState{
CharacterId: req.CharacterId,
RebirthCount: targetCount,
LatestVersion: nowMillis,
}
})
if err != nil {
log.Printf("[CharacterService] Rebirth error: %v", err)
return nil, err
}
rebirthTables := []string{"IUserCharacterRebirth", "IUserMaterial", "IUserConsumableItem"}
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), rebirthTables)
diff := tracker.Apply(snapshot, tables)
return &pb.RebirthResponse{DiffUserData: diff}, nil
}
+180
View File
@@ -0,0 +1,180 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type CharacterBoardServiceServer struct {
pb.UnimplementedCharacterBoardServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CharacterBoardCatalog
}
func NewCharacterBoardServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterBoardCatalog) *CharacterBoardServiceServer {
return &CharacterBoardServiceServer{users: users, sessions: sessions, catalog: catalog}
}
func (s *CharacterBoardServiceServer) ReleasePanel(ctx context.Context, req *pb.ReleasePanelRequest) (*pb.ReleasePanelResponse, error) {
log.Printf("[CharacterBoardService] ReleasePanel: panelIds=%v", req.CharacterBoardPanelId)
userId := currentUserId(ctx, s.users, s.sessions)
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"}).
Track("IUserConsumableItem", oldUser, userdata.SortedConsumableItemRecords, []string{"userId", "consumableItemId"})
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, panelId := range req.CharacterBoardPanelId {
panel, ok := s.catalog.PanelById[panelId]
if !ok {
log.Printf("[CharacterBoardService] unknown panelId=%d, skipping", panelId)
continue
}
s.consumeCosts(user, panel)
s.setReleaseBit(user, panel)
s.applyEffects(user, panel)
}
})
boardTables := []string{
"IUserCharacterBoard",
"IUserCharacterBoardAbility",
"IUserCharacterBoardStatusUp",
"IUserMaterial",
"IUserConsumableItem",
"IUserGem",
}
tables := userdata.SelectTables(userdata.FullClientTableMap(user), boardTables)
diff := tracker.Apply(user, tables)
return &pb.ReleasePanelResponse{DiffUserData: diff}, nil
}
func (s *CharacterBoardServiceServer) consumeCosts(user *store.UserState, panel masterdata.CharacterBoardPanelRow) {
costs := s.catalog.ReleaseCostsByGroupId[panel.CharacterBoardPanelReleasePossessionGroupId]
for _, cost := range costs {
store.DeductPossession(user, model.PossessionType(cost.PossessionType), cost.PossessionId, cost.Count)
}
}
func (s *CharacterBoardServiceServer) setReleaseBit(user *store.UserState, panel masterdata.CharacterBoardPanelRow) {
boardId := panel.CharacterBoardId
board := user.CharacterBoards[boardId]
board.CharacterBoardId = boardId
bitFieldIndex := (panel.SortOrder - 1) / 32
bitPosition := (panel.SortOrder - 1) % 32
mask := int32(1 << uint(bitPosition))
switch bitFieldIndex {
case 0:
board.PanelReleaseBit1 |= mask
case 1:
board.PanelReleaseBit2 |= mask
case 2:
board.PanelReleaseBit3 |= mask
case 3:
board.PanelReleaseBit4 |= mask
}
user.CharacterBoards[boardId] = board
}
func (s *CharacterBoardServiceServer) applyEffects(user *store.UserState, panel masterdata.CharacterBoardPanelRow) {
effects := s.catalog.ReleaseEffectsByGroupId[panel.CharacterBoardPanelReleaseEffectGroupId]
for _, eff := range effects {
switch model.CharacterBoardEffectType(eff.CharacterBoardEffectType) {
case model.CharacterBoardEffectTypeAbility:
s.applyAbilityEffect(user, eff)
case model.CharacterBoardEffectTypeStatusUp:
s.applyStatusUpEffect(user, eff)
}
}
}
func (s *CharacterBoardServiceServer) applyAbilityEffect(user *store.UserState, eff masterdata.CharacterBoardReleaseEffectRow) {
ability, ok := s.catalog.AbilityById[eff.CharacterBoardEffectId]
if !ok {
log.Printf("[CharacterBoardService] unknown abilityId=%d", eff.CharacterBoardEffectId)
return
}
characterId := s.resolveCharacterId(ability.CharacterBoardEffectTargetGroupId)
if characterId == 0 {
return
}
key := store.CharacterBoardAbilityKey{CharacterId: characterId, AbilityId: ability.AbilityId}
state := user.CharacterBoardAbilities[key]
state.CharacterId = characterId
state.AbilityId = ability.AbilityId
state.Level += eff.EffectValue
if maxLvl, ok := s.catalog.AbilityMaxLevel[key]; ok && state.Level > maxLvl {
state.Level = maxLvl
}
user.CharacterBoardAbilities[key] = state
}
func (s *CharacterBoardServiceServer) applyStatusUpEffect(user *store.UserState, eff masterdata.CharacterBoardReleaseEffectRow) {
statusUp, ok := s.catalog.StatusUpById[eff.CharacterBoardEffectId]
if !ok {
log.Printf("[CharacterBoardService] unknown statusUpId=%d", eff.CharacterBoardEffectId)
return
}
characterId := s.resolveCharacterId(statusUp.CharacterBoardEffectTargetGroupId)
if characterId == 0 {
return
}
supType := model.CharacterBoardStatusUpType(statusUp.CharacterBoardStatusUpType)
calcType := model.StatusUpTypeToCalcType(supType)
key := store.CharacterBoardStatusUpKey{
CharacterId: characterId,
StatusCalculationType: int32(calcType),
}
state := user.CharacterBoardStatusUps[key]
state.CharacterId = characterId
state.StatusCalculationType = int32(calcType)
switch supType {
case model.CharacterBoardStatusUpTypeAgilityAdd, model.CharacterBoardStatusUpTypeAgilityMultiply:
state.Agility += eff.EffectValue
case model.CharacterBoardStatusUpTypeAttackAdd, model.CharacterBoardStatusUpTypeAttackMultiply:
state.Attack += eff.EffectValue
case model.CharacterBoardStatusUpTypeCritAttackAdd:
state.CriticalAttack += eff.EffectValue
case model.CharacterBoardStatusUpTypeCritRatioAdd:
state.CriticalRatio += eff.EffectValue
case model.CharacterBoardStatusUpTypeHpAdd, model.CharacterBoardStatusUpTypeHpMultiply:
state.Hp += eff.EffectValue
case model.CharacterBoardStatusUpTypeVitalityAdd, model.CharacterBoardStatusUpTypeVitalityMultiply:
state.Vitality += eff.EffectValue
}
user.CharacterBoardStatusUps[key] = state
}
func (s *CharacterBoardServiceServer) resolveCharacterId(targetGroupId int32) int32 {
targets := s.catalog.EffectTargetsByGroupId[targetGroupId]
for _, t := range targets {
if t.TargetValue != 0 {
return t.TargetValue
}
}
log.Printf("[CharacterBoardService] no characterId resolved for targetGroupId=%d", targetGroupId)
return 0
}
@@ -0,0 +1,68 @@
package service
import (
"context"
"encoding/json"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"google.golang.org/protobuf/types/known/emptypb"
)
type CharacterViewerServiceServer struct {
pb.UnimplementedCharacterViewerServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CharacterViewerCatalog
}
func NewCharacterViewerServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterViewerCatalog) *CharacterViewerServiceServer {
return &CharacterViewerServiceServer{users: users, sessions: sessions, catalog: catalog}
}
func (s *CharacterViewerServiceServer) CharacterViewerTop(ctx context.Context, _ *emptypb.Empty) (*pb.CharacterViewerTopResponse, error) {
log.Printf("[CharacterViewerService] CharacterViewerTop")
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
panic(fmt.Sprintf("CharacterViewerTop: no user for userId=%d: %v", userId, err))
}
released := s.catalog.ReleasedFieldIds(user)
log.Printf("[CharacterViewerService] released %d fields for user %d", len(released), userId)
now := gametime.NowMillis()
records := make([]map[string]any, 0, len(released))
for _, fieldId := range released {
records = append(records, map[string]any{
"userId": userId,
"characterViewerFieldId": fieldId,
"releaseDatetime": now,
"latestVersion": 0,
})
}
payload := "[]"
if len(records) > 0 {
data, _ := json.Marshal(records)
payload = string(data)
}
diff := map[string]*pb.DiffData{
"IUserCharacterViewerField": {
UpdateRecordsJson: payload,
DeleteKeysJson: "[]",
},
}
return &pb.CharacterViewerTopResponse{
ReleaseCharacterViewerFieldId: released,
DiffUserData: diff,
}, nil
}
+86
View File
@@ -0,0 +1,86 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
const companionMaxLevel = int32(50)
var companionDiffTables = []string{
"IUserCompanion",
"IUserMaterial",
"IUserConsumableItem",
}
type CompanionServiceServer struct {
pb.UnimplementedCompanionServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CompanionCatalog
config *masterdata.GameConfig
}
func NewCompanionServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CompanionCatalog, config *masterdata.GameConfig) *CompanionServiceServer {
return &CompanionServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
}
func (s *CompanionServiceServer) Enhance(ctx context.Context, req *pb.CompanionEnhanceRequest) (*pb.CompanionEnhanceResponse, error) {
log.Printf("[CompanionService] Enhance: uuid=%s addLevel=%d", req.UserCompanionUuid, req.AddLevelCount)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
companion, ok := user.Companions[req.UserCompanionUuid]
if !ok {
log.Printf("[CompanionService] Enhance: companion uuid=%s not found", req.UserCompanionUuid)
return
}
compDef, ok := s.catalog.CompanionById[companion.CompanionId]
if !ok {
log.Printf("[CompanionService] Enhance: companion master id=%d not found", companion.CompanionId)
return
}
targetLevel := companion.Level + req.AddLevelCount
if targetLevel > companionMaxLevel {
targetLevel = companionMaxLevel
}
for lvl := companion.Level; lvl < targetLevel; lvl++ {
if costFunc, ok := s.catalog.GoldCostByCategory[compDef.CompanionCategoryType]; ok {
goldCost := costFunc.Evaluate(lvl)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
}
matKey := masterdata.CompanionLevelKey{CategoryType: compDef.CompanionCategoryType, Level: lvl}
if mat, ok := s.catalog.MaterialsByKey[matKey]; ok {
user.Materials[mat.MaterialId] -= mat.Count
}
}
companion.Level = targetLevel
companion.LatestVersion = nowMillis
user.Companions[req.UserCompanionUuid] = companion
log.Printf("[CompanionService] Enhance: companionId=%d level -> %d", companion.CompanionId, targetLevel)
})
if err != nil {
return nil, fmt.Errorf("companion enhance: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, companionDiffTables))
return &pb.CompanionEnhanceResponse{
DiffUserData: diff,
}, nil
}
+47
View File
@@ -0,0 +1,47 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/userdata"
"google.golang.org/protobuf/types/known/emptypb"
)
type ConfigServiceServer struct {
pb.UnimplementedConfigServiceServer
GrpcHost string
GrpcPort int32
OctoURL string // HTTP base URL for Octo (list + assets); client uses this instead of default resources.app.nierreincarnation.com
}
func NewConfigServiceServer(host string, port int32, octoURL string) *ConfigServiceServer {
return &ConfigServiceServer{GrpcHost: host, GrpcPort: port, OctoURL: octoURL}
}
func (s *ConfigServiceServer) GetReviewServerConfig(ctx context.Context, _ *emptypb.Empty) (*pb.GetReviewServerConfigResponse, error) {
log.Printf("[ConfigService] GetReviewServerConfig -> %s:%d", s.GrpcHost, s.GrpcPort)
return &pb.GetReviewServerConfigResponse{
Api: &pb.ApiConfig{
Hostname: s.GrpcHost,
Port: s.GrpcPort,
},
Octo: &pb.OctoConfig{
Version: 1,
AppId: 1,
ClientSecretKey: "secret",
AesKey: "aeskey",
Url: s.OctoURL,
},
WebView: &pb.WebViewConfig{
BaseUrl: s.OctoURL,
},
MasterData: &pb.MasterDataConfig{
UrlFormat: s.OctoURL + "/master-data/%s",
},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
+43
View File
@@ -0,0 +1,43 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type ContentsStoryServiceServer struct {
pb.UnimplementedContentsStoryServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewContentsStoryServiceServer(users store.UserRepository, sessions store.SessionRepository) *ContentsStoryServiceServer {
return &ContentsStoryServiceServer{users: users, sessions: sessions}
}
func (s *ContentsStoryServiceServer) RegisterPlayed(ctx context.Context, req *pb.ContentsStoryRegisterPlayedRequest) (*pb.ContentsStoryRegisterPlayedResponse, error) {
log.Printf("[ContentsStoryService] RegisterPlayed: contentsStoryId=%d", req.ContentsStoryId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
user.ContentsStories[req.ContentsStoryId] = nowMillis
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserContentsStory"}))
return &pb.ContentsStoryRegisterPlayedResponse{
DiffUserData: diff,
}, nil
}
+396
View File
@@ -0,0 +1,396 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/gameutil"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
var costumeDiffTables = []string{
"IUserCostume",
"IUserMaterial",
"IUserConsumableItem",
}
type CostumeServiceServer struct {
pb.UnimplementedCostumeServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CostumeCatalog
config *masterdata.GameConfig
}
func NewCostumeServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CostumeCatalog, config *masterdata.GameConfig) *CostumeServiceServer {
return &CostumeServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
}
func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceRequest) (*pb.EnhanceResponse, error) {
log.Printf("[CostumeService] Enhance: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
costume, ok := user.Costumes[req.UserCostumeUuid]
if !ok {
log.Printf("[CostumeService] Enhance: costume uuid=%s not found", req.UserCostumeUuid)
return
}
cm, ok := s.catalog.Costumes[costume.CostumeId]
if !ok {
log.Printf("[CostumeService] Enhance: costume master id=%d not found", costume.CostumeId)
return
}
totalExp := int32(0)
totalMaterialCount := int32(0)
for materialId, count := range req.Materials {
mat, ok := s.catalog.Materials[materialId]
if !ok {
log.Printf("[CostumeService] Enhance: material id=%d not found, skipping", materialId)
continue
}
cur := user.Materials[materialId]
if cur < count {
log.Printf("[CostumeService] Enhance: insufficient material id=%d have=%d need=%d", materialId, cur, count)
continue
}
user.Materials[materialId] = cur - count
totalMaterialCount += count
expPerUnit := mat.EffectValue
if mat.WeaponType != 0 && mat.WeaponType == cm.SkillfulWeaponType {
expPerUnit = expPerUnit * s.config.MaterialSameWeaponExpCoefficientPermil / 1000
}
totalExp += expPerUnit * count
}
if costFunc, ok := s.catalog.EnhanceCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
log.Printf("[CostumeService] Enhance: gold cost=%d (materials=%d)", goldCost, totalMaterialCount)
}
costume.Exp += totalExp
if thresholds, ok := s.catalog.ExpByRarity[cm.RarityType]; ok {
costume.Level, costume.Exp = gameutil.LevelAndCap(costume.Exp, thresholds)
}
costume.LatestVersion = nowMillis
user.Costumes[req.UserCostumeUuid] = costume
log.Printf("[CostumeService] Enhance: costumeId=%d +%d exp -> total=%d level=%d", costume.CostumeId, totalExp, costume.Exp, costume.Level)
})
if err != nil {
return nil, fmt.Errorf("costume enhance: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, costumeDiffTables))
return &pb.EnhanceResponse{
IsGreatSuccess: false,
SurplusEnhanceMaterial: map[int32]int32{},
DiffUserData: diff,
}, nil
}
var awakenDiffTables = []string{
"IUserCostume",
"IUserMaterial",
"IUserConsumableItem",
"IUserCostumeAwakenStatusUp",
"IUserThought",
}
func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest) (*pb.AwakenResponse, error) {
log.Printf("[CostumeService] Awaken: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
costume, ok := user.Costumes[req.UserCostumeUuid]
if !ok {
log.Printf("[CostumeService] Awaken: costume uuid=%s not found", req.UserCostumeUuid)
return
}
awakenRow, ok := s.catalog.AwakenByCostumeId[costume.CostumeId]
if !ok {
log.Printf("[CostumeService] Awaken: no awaken data for costumeId=%d", costume.CostumeId)
return
}
nextStep := costume.AwakenCount + 1
if gold, ok := s.catalog.AwakenPriceByGroup[awakenRow.CostumeAwakenPriceGroupId]; ok {
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= gold
log.Printf("[CostumeService] Awaken: gold cost=%d", gold)
}
for materialId, count := range req.Materials {
cur := user.Materials[materialId]
if cur < count {
log.Printf("[CostumeService] Awaken: insufficient material id=%d have=%d need=%d", materialId, cur, count)
count = cur
}
user.Materials[materialId] = cur - count
}
costume.AwakenCount = nextStep
costume.LatestVersion = nowMillis
user.Costumes[req.UserCostumeUuid] = costume
log.Printf("[CostumeService] Awaken: costumeId=%d awakenCount=%d", costume.CostumeId, nextStep)
effectSteps, ok := s.catalog.AwakenEffectsByGroupAndStep[awakenRow.CostumeAwakenEffectGroupId]
if !ok {
return
}
effect, ok := effectSteps[nextStep]
if !ok {
return
}
switch model.CostumeAwakenEffectType(effect.CostumeAwakenEffectType) {
case model.CostumeAwakenEffectTypeStatusUp:
s.applyAwakenStatusUp(user, req.UserCostumeUuid, effect.CostumeAwakenEffectId, nowMillis)
case model.CostumeAwakenEffectTypeAbility:
log.Printf("[CostumeService] Awaken: ability effect id=%d (client-resolved)", effect.CostumeAwakenEffectId)
case model.CostumeAwakenEffectTypeItemAcquire:
s.applyAwakenItemAcquire(user, effect.CostumeAwakenEffectId, nowMillis)
default:
log.Printf("[CostumeService] Awaken: unknown effect type=%d", effect.CostumeAwakenEffectType)
}
})
if err != nil {
return nil, fmt.Errorf("costume awaken: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, awakenDiffTables))
return &pb.AwakenResponse{
DiffUserData: diff,
}, nil
}
func (s *CostumeServiceServer) applyAwakenStatusUp(user *store.UserState, costumeUuid string, statusUpGroupId int32, nowMillis int64) {
rows, ok := s.catalog.AwakenStatusUpByGroup[statusUpGroupId]
if !ok {
log.Printf("[CostumeService] Awaken: status up group %d not found", statusUpGroupId)
return
}
for _, row := range rows {
calcType := model.StatusCalculationType(row.StatusCalculationType)
key := store.CostumeAwakenStatusKey{
UserCostumeUuid: costumeUuid,
StatusCalculationType: calcType,
}
state := user.CostumeAwakenStatusUps[key]
state.UserCostumeUuid = costumeUuid
state.StatusCalculationType = calcType
switch model.StatusKindType(row.StatusKindType) {
case model.StatusKindTypeHp:
state.Hp += row.EffectValue
case model.StatusKindTypeAttack:
state.Attack += row.EffectValue
case model.StatusKindTypeVitality:
state.Vitality += row.EffectValue
case model.StatusKindTypeAgility:
state.Agility += row.EffectValue
case model.StatusKindTypeCriticalRatio:
state.CriticalRatio += row.EffectValue
case model.StatusKindTypeCriticalAttack:
state.CriticalAttack += row.EffectValue
}
state.LatestVersion = nowMillis
user.CostumeAwakenStatusUps[key] = state
}
}
func (s *CostumeServiceServer) applyAwakenItemAcquire(user *store.UserState, itemAcquireId int32, nowMillis int64) {
acq, ok := s.catalog.AwakenItemAcquireById[itemAcquireId]
if !ok {
log.Printf("[CostumeService] Awaken: item acquire id=%d not found", itemAcquireId)
return
}
key := fmt.Sprintf("awaken-thought-%d", acq.PossessionId)
if _, exists := user.Thoughts[key]; exists {
return
}
user.Thoughts[key] = store.ThoughtState{
UserThoughtUuid: key,
ThoughtId: acq.PossessionId,
AcquisitionDatetime: nowMillis,
LatestVersion: nowMillis,
}
log.Printf("[CostumeService] Awaken: granted thought id=%d", acq.PossessionId)
}
var activeSkillDiffTables = []string{
"IUserCostumeActiveSkill",
"IUserMaterial",
"IUserConsumableItem",
}
func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.EnhanceActiveSkillRequest) (*pb.EnhanceActiveSkillResponse, error) {
log.Printf("[CostumeService] EnhanceActiveSkill: uuid=%s addLevel=%d", req.UserCostumeUuid, req.AddLevelCount)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
costume, ok := user.Costumes[req.UserCostumeUuid]
if !ok {
log.Printf("[CostumeService] EnhanceActiveSkill: costume uuid=%s not found", req.UserCostumeUuid)
return
}
cm, ok := s.catalog.Costumes[costume.CostumeId]
if !ok {
log.Printf("[CostumeService] EnhanceActiveSkill: costume master id=%d not found", costume.CostumeId)
return
}
groupRows := s.catalog.ActiveSkillGroupsByGroupId[cm.CostumeActiveSkillGroupId]
enhanceMatId := int32(-1)
for _, g := range groupRows {
if g.CostumeLimitBreakCountLowerLimit <= costume.LimitBreakCount {
enhanceMatId = g.CostumeActiveSkillEnhancementMaterialId
break
}
}
if enhanceMatId < 0 {
log.Printf("[CostumeService] EnhanceActiveSkill: no skill group for costumeId=%d groupId=%d lb=%d",
costume.CostumeId, cm.CostumeActiveSkillGroupId, costume.LimitBreakCount)
return
}
skill := user.CostumeActiveSkills[req.UserCostumeUuid]
currentLevel := skill.Level
maxLevelFunc, ok := s.catalog.ActiveSkillMaxLevelByRarity[cm.RarityType]
if !ok {
log.Printf("[CostumeService] EnhanceActiveSkill: no max level func for rarity=%d", cm.RarityType)
return
}
maxLevel := maxLevelFunc.Evaluate(0)
addCount := req.AddLevelCount
if currentLevel+addCount > maxLevel {
addCount = maxLevel - currentLevel
}
if addCount <= 0 {
log.Printf("[CostumeService] EnhanceActiveSkill: already at max level %d", currentLevel)
return
}
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
key := [2]int32{enhanceMatId, lvl}
mats := s.catalog.ActiveSkillEnhanceMats[key]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
if cur < cost {
log.Printf("[CostumeService] EnhanceActiveSkill: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost)
cost = cur
}
user.Materials[mat.MaterialId] = cur - cost
}
if costFunc, ok := s.catalog.ActiveSkillCostByRarity[cm.RarityType]; ok {
goldCost := costFunc.Evaluate(lvl + 1)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
}
}
skill.UserCostumeUuid = req.UserCostumeUuid
skill.Level = currentLevel + addCount
skill.LatestVersion = nowMillis
user.CostumeActiveSkills[req.UserCostumeUuid] = skill
log.Printf("[CostumeService] EnhanceActiveSkill: costumeId=%d level %d -> %d", costume.CostumeId, currentLevel, skill.Level)
})
if err != nil {
return nil, fmt.Errorf("costume enhance active skill: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, activeSkillDiffTables))
return &pb.EnhanceActiveSkillResponse{
DiffUserData: diff,
}, nil
}
func (s *CostumeServiceServer) LimitBreak(ctx context.Context, req *pb.LimitBreakRequest) (*pb.LimitBreakResponse, error) {
log.Printf("[CostumeService] LimitBreak: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
costume, ok := user.Costumes[req.UserCostumeUuid]
if !ok {
log.Printf("[CostumeService] LimitBreak: costume uuid=%s not found", req.UserCostumeUuid)
return
}
if costume.LimitBreakCount >= s.config.CostumeLimitBreakAvailableCount {
log.Printf("[CostumeService] LimitBreak: already at max limit break %d", costume.LimitBreakCount)
return
}
cm, ok := s.catalog.Costumes[costume.CostumeId]
if !ok {
log.Printf("[CostumeService] LimitBreak: costume master id=%d not found", costume.CostumeId)
return
}
totalMaterialCount := int32(0)
for materialId, count := range req.Materials {
cur := user.Materials[materialId]
if cur < count {
log.Printf("[CostumeService] LimitBreak: insufficient material id=%d have=%d need=%d", materialId, cur, count)
count = cur
}
user.Materials[materialId] = cur - count
totalMaterialCount += count
}
if costFunc, ok := s.catalog.LimitBreakCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
log.Printf("[CostumeService] LimitBreak: gold cost=%d", goldCost)
}
costume.LimitBreakCount++
costume.LatestVersion = nowMillis
user.Costumes[req.UserCostumeUuid] = costume
log.Printf("[CostumeService] LimitBreak: costumeId=%d limitBreak -> %d", costume.CostumeId, costume.LimitBreakCount)
})
if err != nil {
return nil, fmt.Errorf("costume limit break: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, costumeDiffTables))
return &pb.LimitBreakResponse{
DiffUserData: diff,
}, nil
}
+166
View File
@@ -0,0 +1,166 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
"google.golang.org/protobuf/types/known/emptypb"
)
type DataServiceServer struct {
pb.UnimplementedDataServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewDataServiceServer(users store.UserRepository, sessions store.SessionRepository) *DataServiceServer {
return &DataServiceServer{users: users, sessions: sessions}
}
func (s *DataServiceServer) GetLatestMasterDataVersion(ctx context.Context, _ *emptypb.Empty) (*pb.MasterDataGetLatestVersionResponse, error) {
log.Printf("[DataService] GetLatestMasterDataVersion")
return &pb.MasterDataGetLatestVersionResponse{
LatestMasterDataVersion: "20240404193219",
}, nil
}
func (s *DataServiceServer) GetUserDataNameV2(ctx context.Context, _ *emptypb.Empty) (*pb.UserDataGetNameResponseV2, error) {
log.Printf("[DataService] GetUserDataNameV2")
return &pb.UserDataGetNameResponseV2{
TableNameList: []*pb.TableNameList{
{TableName: defaultTableNames()},
},
}, nil
}
func (s *DataServiceServer) GetUserData(ctx context.Context, req *pb.UserDataGetRequest) (*pb.UserDataGetResponse, error) {
log.Printf("[DataService] GetUserData: tables=%v", req.TableName)
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return nil, fmt.Errorf("snapshot user: %w", err)
}
defaults := userdata.FirstEntranceClientTableMap(user)
result := userdata.SelectTables(defaults, req.TableName)
return &pb.UserDataGetResponse{
UserDataJson: result,
}, nil
}
func defaultTableNames() []string {
return []string{
"IUser",
"IUserApple",
"IUserAutoSaleSettingDetail",
"IUserBeginnerCampaign",
"IUserBigHuntMaxScore",
"IUserBigHuntProgressStatus",
"IUserBigHuntScheduleMaxScore",
"IUserBigHuntStatus",
"IUserBigHuntWeeklyMaxScore",
"IUserBigHuntWeeklyStatus",
"IUserCageOrnamentReward",
"IUserCharacter",
"IUserCharacterBoard",
"IUserCharacterBoardAbility",
"IUserCharacterBoardCompleteReward",
"IUserCharacterBoardStatusUp",
"IUserCharacterCostumeLevelBonus",
"IUserCharacterRebirth",
"IUserCharacterViewerField",
"IUserComebackCampaign",
"IUserCompanion",
"IUserConsumableItem",
"IUserContentsStory",
"IUserCostume",
"IUserCostumeActiveSkill",
"IUserCostumeAwakenStatusUp",
"IUserCostumeLevelBonusReleaseStatus",
"IUserCostumeLotteryEffect",
"IUserCostumeLotteryEffectAbility",
"IUserCostumeLotteryEffectPending",
"IUserCostumeLotteryEffectStatusUp",
"IUserDeck",
"IUserDeckCharacter",
"IUserDeckCharacterDressupCostume",
"IUserDeckLimitContentRestricted",
"IUserDeckPartsGroup",
"IUserDeckSubWeaponGroup",
"IUserDeckTypeNote",
"IUserDokan",
"IUserEventQuestDailyGroupCompleteReward",
"IUserEventQuestGuerrillaFreeOpen",
"IUserEventQuestLabyrinthSeason",
"IUserEventQuestLabyrinthStage",
"IUserEventQuestProgressStatus",
"IUserEventQuestTowerAccumulationReward",
"IUserExplore",
"IUserExploreScore",
"IUserExtraQuestProgressStatus",
"IUserFacebook",
"IUserGem",
"IUserGimmick",
"IUserGimmickOrnamentProgress",
"IUserGimmickSequence",
"IUserGimmickUnlock",
"IUserImportantItem",
"IUserLimitedOpen",
// "IUserLogin",
"IUserLoginBonus",
"IUserMainQuestFlowStatus",
"IUserMainQuestMainFlowStatus",
"IUserMainQuestProgressStatus",
"IUserMainQuestReplayFlowStatus",
"IUserMainQuestSeasonRoute",
"IUserMaterial",
"IUserMission",
"IUserMissionCompletionProgress",
"IUserMissionPassPoint",
"IUserMovie",
"IUserNaviCutIn",
"IUserOmikuji",
"IUserParts",
"IUserPartsGroupNote",
"IUserPartsPreset",
"IUserPartsPresetTag",
"IUserPartsStatusSub",
"IUserPortalCageStatus",
"IUserPossessionAutoConvert",
"IUserPremiumItem",
"IUserProfile",
"IUserPvpDefenseDeck",
"IUserPvpStatus",
"IUserPvpWeeklyResult",
"IUserQuest",
"IUserQuestAutoOrbit",
"IUserQuestLimitContentStatus",
"IUserQuestMission",
"IUserQuestReplayFlowRewardGroup",
"IUserQuestSceneChoice",
"IUserQuestSceneChoiceHistory",
// "IUserSetting",
"IUserShopItem",
"IUserShopReplaceable",
"IUserShopReplaceableLineup",
"IUserSideStoryQuest",
"IUserSideStoryQuestSceneProgressStatus",
"IUserStatus",
"IUserThought",
"IUserTripleDeck",
"IUserTutorialProgress",
"IUserWeapon",
"IUserWeaponAbility",
"IUserWeaponAwaken",
"IUserWeaponNote",
"IUserWeaponSkill",
"IUserWeaponStory",
"IUserWebviewPanelMission",
}
}
+241
View File
@@ -0,0 +1,241 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type DeckServiceServer struct {
pb.UnimplementedDeckServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewDeckServiceServer(users store.UserRepository, sessions store.SessionRepository) *DeckServiceServer {
return &DeckServiceServer{users: users, sessions: sessions}
}
func (s *DeckServiceServer) UpdateName(ctx context.Context, req *pb.UpdateNameRequest) (*pb.UpdateNameResponse, error) {
log.Printf("[DeckService] UpdateName: deckType=%d deckNumber=%d name=%q", req.DeckType, req.UserDeckNumber, req.Name)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
deckKey := store.DeckKey{DeckType: model.DeckType(req.DeckType), UserDeckNumber: req.UserDeckNumber}
deck := user.Decks[deckKey]
deck.Name = req.Name
user.Decks[deckKey] = deck
})
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserDeck"})
return &pb.UpdateNameResponse{
DiffUserData: userdata.BuildDiffFromTables(result),
}, nil
}
func (s *DeckServiceServer) RefreshDeckPower(ctx context.Context, req *pb.RefreshDeckPowerRequest) (*pb.RefreshDeckPowerResponse, error) {
log.Printf("[DeckService] RefreshDeckPower: deckType=%d deckNumber=%d", req.DeckType, req.UserDeckNumber)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
if req.DeckPower == nil {
log.Printf("[DeckService] RefreshDeckPower: deckPower is nil")
return
}
dt := model.DeckType(req.DeckType)
deckKey := store.DeckKey{DeckType: dt, UserDeckNumber: req.UserDeckNumber}
deck, ok := user.Decks[deckKey]
if !ok {
log.Fatalf("[DeckService] RefreshDeckPower: deck not found")
}
deck.Power = req.DeckPower.Power
user.Decks[deckKey] = deck
for _, cp := range []*pb.DeckCharacterPower{
req.DeckPower.DeckCharacterPower01,
req.DeckPower.DeckCharacterPower02,
req.DeckPower.DeckCharacterPower03,
} {
if cp == nil || cp.UserDeckCharacterUuid == "" {
continue
}
if dc, ok := user.DeckCharacters[cp.UserDeckCharacterUuid]; ok {
dc.Power = cp.Power
user.DeckCharacters[cp.UserDeckCharacterUuid] = dc
}
}
note := user.DeckTypeNotes[dt]
if req.DeckPower.Power > note.MaxDeckPower {
note.DeckType = dt
note.MaxDeckPower = req.DeckPower.Power
user.DeckTypeNotes[dt] = note
}
})
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
"IUserDeck", "IUserDeckCharacter", "IUserDeckTypeNote",
})
return &pb.RefreshDeckPowerResponse{
DiffUserData: userdata.BuildDiffFromTables(result),
}, nil
}
func (s *DeckServiceServer) RefreshMultiDeckPower(ctx context.Context, req *pb.RefreshMultiDeckPowerRequest) (*pb.RefreshMultiDeckPowerResponse, error) {
log.Printf("[DeckService] RefreshMultiDeckPower: %d entries", len(req.DeckPowerInfo))
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, info := range req.DeckPowerInfo {
if info.DeckPower == nil {
continue
}
dt := model.DeckType(info.DeckType)
deckKey := store.DeckKey{DeckType: dt, UserDeckNumber: info.UserDeckNumber}
deck, ok := user.Decks[deckKey]
if !ok {
log.Printf("[DeckService] RefreshMultiDeckPower: deck not found deckType=%d deckNumber=%d", info.DeckType, info.UserDeckNumber)
continue
}
deck.Power = info.DeckPower.Power
user.Decks[deckKey] = deck
for _, cp := range []*pb.DeckCharacterPower{
info.DeckPower.DeckCharacterPower01,
info.DeckPower.DeckCharacterPower02,
info.DeckPower.DeckCharacterPower03,
} {
if cp == nil || cp.UserDeckCharacterUuid == "" {
continue
}
if dc, ok := user.DeckCharacters[cp.UserDeckCharacterUuid]; ok {
dc.Power = cp.Power
user.DeckCharacters[cp.UserDeckCharacterUuid] = dc
}
}
note := user.DeckTypeNotes[dt]
if info.DeckPower.Power > note.MaxDeckPower {
note.DeckType = dt
note.MaxDeckPower = info.DeckPower.Power
user.DeckTypeNotes[dt] = note
}
}
})
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
"IUserDeck", "IUserDeckCharacter", "IUserDeckTypeNote",
})
return &pb.RefreshMultiDeckPowerResponse{
DiffUserData: userdata.BuildDiffFromTables(result),
}, nil
}
func deckSlotsFromProto(deck *pb.Deck) []store.DeckCharacterInput {
slots := make([]store.DeckCharacterInput, 3)
for i, ch := range []*pb.DeckCharacter{deck.Character01, deck.Character02, deck.Character03} {
if ch == nil {
continue
}
slots[i] = store.DeckCharacterInput{
UserCostumeUuid: ch.UserCostumeUuid,
MainUserWeaponUuid: ch.MainUserWeaponUuid,
SubWeaponUuids: ch.SubUserWeaponUuid,
PartsUuids: ch.UserPartsUuid,
UserCompanionUuid: ch.UserCompanionUuid,
UserThoughtUuid: ch.UserThoughtUuid,
DressupCostumeId: ch.DressupCostumeId,
}
}
return slots
}
func (s *DeckServiceServer) ReplaceDeck(ctx context.Context, req *pb.ReplaceDeckRequest) (*pb.ReplaceDeckResponse, error) {
log.Printf("[DeckService] ReplaceDeck: deckType=%d deckNumber=%d", req.DeckType, req.UserDeckNumber)
if req.Deck != nil {
for i, ch := range []*pb.DeckCharacter{req.Deck.Character01, req.Deck.Character02, req.Deck.Character03} {
if ch == nil {
continue
}
log.Printf("[DeckService] ReplaceDeck slot %d: costume=%s mainWeapon=%s subWeapons=%v companion=%s thought=%s",
i+1, ch.UserCostumeUuid, ch.MainUserWeaponUuid, ch.SubUserWeaponUuid, ch.UserCompanionUuid, ch.UserThoughtUuid)
}
}
userId := currentUserId(ctx, s.users, s.sessions)
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserDeckSubWeaponGroup", oldUser, userdata.DeckSubWeaponRecords,
[]string{"userId", "userDeckCharacterUuid", "userWeaponUuid"}).
Track("IUserDeckPartsGroup", oldUser, userdata.DeckPartsGroupRecords,
[]string{"userId", "userDeckCharacterUuid", "userPartsUuid"}).
Track("IUserDeckCharacterDressupCostume", oldUser, userdata.DeckDressupCostumeRecords,
[]string{"userId", "userDeckCharacterUuid"})
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
if req.Deck == nil {
return
}
store.ApplyDeckReplacement(user, model.DeckType(req.DeckType), req.UserDeckNumber, deckSlotsFromProto(req.Deck), gametime.NowMillis())
})
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
"IUserDeck", "IUserDeckCharacter", "IUserDeckSubWeaponGroup", "IUserDeckPartsGroup",
"IUserDeckCharacterDressupCostume",
})
return &pb.ReplaceDeckResponse{
DiffUserData: tracker.Apply(user, result),
}, nil
}
func (s *DeckServiceServer) ReplaceTripleDeck(ctx context.Context, req *pb.ReplaceTripleDeckRequest) (*pb.ReplaceTripleDeckResponse, error) {
log.Printf("[DeckService] ReplaceTripleDeck: deckType=%d deckNumber=%d", req.DeckType, req.UserDeckNumber)
userId := currentUserId(ctx, s.users, s.sessions)
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserDeckSubWeaponGroup", oldUser, userdata.DeckSubWeaponRecords,
[]string{"userId", "userDeckCharacterUuid", "userWeaponUuid"}).
Track("IUserDeckPartsGroup", oldUser, userdata.DeckPartsGroupRecords,
[]string{"userId", "userDeckCharacterUuid", "userPartsUuid"}).
Track("IUserDeckCharacterDressupCostume", oldUser, userdata.DeckDressupCostumeRecords,
[]string{"userId", "userDeckCharacterUuid"})
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
nowMillis := gametime.NowMillis()
for idx, detail := range []*pb.DeckDetail{req.DeckDetail01, req.DeckDetail02, req.DeckDetail03} {
if detail == nil || detail.Deck == nil {
continue
}
log.Printf("[DeckService] ReplaceTripleDeck detail %d: deckType=%d deckNumber=%d", idx+1, detail.DeckType, detail.UserDeckNumber)
if detail.Deck != nil {
for i, ch := range []*pb.DeckCharacter{detail.Deck.Character01, detail.Deck.Character02, detail.Deck.Character03} {
if ch == nil {
continue
}
log.Printf("[DeckService] ReplaceTripleDeck detail %d slot %d: costume=%s mainWeapon=%s subWeapons=%v companion=%s thought=%s",
idx+1, i+1, ch.UserCostumeUuid, ch.MainUserWeaponUuid, ch.SubUserWeaponUuid, ch.UserCompanionUuid, ch.UserThoughtUuid)
}
}
store.ApplyDeckReplacement(user, model.DeckType(detail.DeckType), detail.UserDeckNumber, deckSlotsFromProto(detail.Deck), nowMillis)
}
})
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
"IUserDeck", "IUserDeckCharacter", "IUserDeckSubWeaponGroup", "IUserDeckPartsGroup",
"IUserDeckCharacterDressupCostume",
})
return &pb.ReplaceTripleDeckResponse{
DiffUserData: tracker.Apply(user, result),
}, nil
}
+42
View File
@@ -0,0 +1,42 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type DokanServiceServer struct {
pb.UnimplementedDokanServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewDokanServiceServer(users store.UserRepository, sessions store.SessionRepository) *DokanServiceServer {
return &DokanServiceServer{users: users, sessions: sessions}
}
func (s *DokanServiceServer) RegisterDokanConfirmed(ctx context.Context, req *pb.RegisterDokanConfirmedRequest) (*pb.RegisterDokanConfirmedResponse, error) {
log.Printf("[DokanService] RegisterDokanConfirmed: dokanIds=%v", req.DokanId)
userId := currentUserId(ctx, s.users, s.sessions)
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, id := range req.DokanId {
user.DokanConfirmed[id] = true
}
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserDokan"}))
return &pb.RegisterDokanConfirmedResponse{
DiffUserData: diff,
}, nil
}
+170
View File
@@ -0,0 +1,170 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
const (
exploreStaminaRecovery = 1000 // millivalue added on finish
exploreRewardMaterialId = 100001
exploreRewardBaseCount = 1
)
var exploreDiffTables = []string{
"IUserExplore",
"IUserExploreScore",
}
var exploreFinishDiffTables = []string{
"IUserExplore",
"IUserExploreScore",
"IUserMaterial",
"IUserStatus",
}
type ExploreServiceServer struct {
pb.UnimplementedExploreServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.ExploreCatalog
}
func NewExploreServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.ExploreCatalog) *ExploreServiceServer {
return &ExploreServiceServer{users: users, sessions: sessions, catalog: catalog}
}
func (s *ExploreServiceServer) StartExplore(ctx context.Context, req *pb.StartExploreRequest) (*pb.StartExploreResponse, error) {
log.Printf("[ExploreService] StartExplore: exploreId=%d useConsumableItemId=%d", req.ExploreId, req.UseConsumableItemId)
if _, ok := s.catalog.Explores[req.ExploreId]; !ok {
return nil, fmt.Errorf("explore id=%d not found", req.ExploreId)
}
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
explore := s.catalog.Explores[req.ExploreId]
if req.UseConsumableItemId > 0 && explore.ConsumeItemCount > 0 {
cur := user.ConsumableItems[req.UseConsumableItemId]
user.ConsumableItems[req.UseConsumableItemId] = cur - explore.ConsumeItemCount
log.Printf("[ExploreService] StartExplore: consumed item=%d count=%d remaining=%d", req.UseConsumableItemId, explore.ConsumeItemCount, user.ConsumableItems[req.UseConsumableItemId])
}
user.Explore = store.ExploreState{
PlayingExploreId: req.ExploreId,
IsUseExploreTicket: false,
LatestPlayDatetime: nowMillis,
LatestVersion: nowMillis,
}
})
if err != nil {
return nil, fmt.Errorf("start explore: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, exploreDiffTables))
return &pb.StartExploreResponse{
DiffUserData: diff,
}, nil
}
func (s *ExploreServiceServer) FinishExplore(ctx context.Context, req *pb.FinishExploreRequest) (*pb.FinishExploreResponse, error) {
log.Printf("[ExploreService] FinishExplore: exploreId=%d score=%d", req.ExploreId, req.Score)
explore, ok := s.catalog.Explores[req.ExploreId]
if !ok {
return nil, fmt.Errorf("explore id=%d not found", req.ExploreId)
}
assetGradeIconId := s.catalog.GradeForScore(req.ExploreId, req.Score)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
rewardCount := int32(exploreRewardBaseCount) * explore.RewardLotteryCount
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
existing, exists := user.ExploreScores[req.ExploreId]
if !exists || req.Score > existing.MaxScore {
user.ExploreScores[req.ExploreId] = store.ExploreScoreState{
ExploreId: req.ExploreId,
MaxScore: req.Score,
MaxScoreUpdateDatetime: nowMillis,
LatestVersion: nowMillis,
}
}
user.Explore = store.ExploreState{
PlayingExploreId: 0,
IsUseExploreTicket: false,
LatestPlayDatetime: user.Explore.LatestPlayDatetime,
LatestVersion: nowMillis,
}
user.Status.StaminaMilliValue += exploreStaminaRecovery
user.Status.StaminaUpdateDatetime = nowMillis
user.Status.LatestVersion = nowMillis
log.Printf("[ExploreService] FinishExplore: stamina +%d -> %d", exploreStaminaRecovery, user.Status.StaminaMilliValue)
user.Materials[exploreRewardMaterialId] += rewardCount
log.Printf("[ExploreService] FinishExplore: granted material=%d count=%d", exploreRewardMaterialId, rewardCount)
})
if err != nil {
return nil, fmt.Errorf("finish explore: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, exploreFinishDiffTables))
rewards := []*pb.ExploreReward{
{
PossessionType: int32(model.PossessionTypeMaterial),
PossessionId: exploreRewardMaterialId,
Count: rewardCount,
},
}
return &pb.FinishExploreResponse{
AcquireStaminaCount: exploreStaminaRecovery,
ExploreReward: rewards,
AssetGradeIconId: assetGradeIconId,
DiffUserData: diff,
}, nil
}
func (s *ExploreServiceServer) RetireExplore(ctx context.Context, req *pb.RetireExploreRequest) (*pb.RetireExploreResponse, error) {
log.Printf("[ExploreService] RetireExplore: exploreId=%d", req.ExploreId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
user.Explore = store.ExploreState{
PlayingExploreId: 0,
IsUseExploreTicket: false,
LatestPlayDatetime: user.Explore.LatestPlayDatetime,
LatestVersion: nowMillis,
}
})
if err != nil {
return nil, fmt.Errorf("retire explore: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserExplore"}))
return &pb.RetireExploreResponse{
DiffUserData: diff,
}, nil
}
+53
View File
@@ -0,0 +1,53 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
type FriendServiceServer struct {
pb.UnimplementedFriendServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewFriendServiceServer(users store.UserRepository, sessions store.SessionRepository) *FriendServiceServer {
return &FriendServiceServer{users: users, sessions: sessions}
}
func (s *FriendServiceServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
log.Printf("[FriendService] GetUser: playerId=%d", req.PlayerId)
return &pb.GetUserResponse{DiffUserData: userdata.EmptyDiff()}, nil
}
func (s *FriendServiceServer) GetFriendList(ctx context.Context, req *pb.GetFriendListRequest) (*pb.GetFriendListResponse, error) {
log.Printf("[FriendService] GetFriendList")
return &pb.GetFriendListResponse{
FriendUser: []*pb.FriendUser{},
SendCheerCount: 0,
ReceivedCheerCount: 0,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *FriendServiceServer) GetFriendRequestList(ctx context.Context, req *emptypb.Empty) (*pb.GetFriendRequestListResponse, error) {
log.Printf("[FriendService] GetFriendRequestList")
return &pb.GetFriendRequestListResponse{
User: []*pb.User{},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *FriendServiceServer) SearchRecommendedUsers(ctx context.Context, req *emptypb.Empty) (*pb.SearchRecommendedUsersResponse, error) {
log.Printf("[FriendService] SearchRecommendedUsers")
return &pb.SearchRecommendedUsersResponse{
Users: []*pb.User{},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
+647
View File
@@ -0,0 +1,647 @@
package service
import (
"context"
"fmt"
"log"
"time"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
var gachaDiffTables = []string{
"IUserGem",
"IUserCostume",
"IUserWeapon",
"IUserConsumableItem",
"IUserCostumeActiveSkill",
"IUserWeaponNote",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserWeaponStory",
"IUserCharacter",
"IUserMaterial",
}
type GachaServiceServer struct {
pb.UnimplementedGachaServiceServer
users store.UserRepository
sessions store.SessionRepository
gacha store.GachaRepository
handler *gacha.GachaHandler
}
func NewGachaServiceServer(
users store.UserRepository,
sessions store.SessionRepository,
gachaRepo store.GachaRepository,
handler *gacha.GachaHandler,
) *GachaServiceServer {
return &GachaServiceServer{
users: users,
sessions: sessions,
gacha: gachaRepo,
handler: handler,
}
}
func (s *GachaServiceServer) GetGachaList(ctx context.Context, req *pb.GetGachaListRequest) (*pb.GetGachaListResponse, error) {
log.Printf("[GachaService] GetGachaList: labels=%v", req.GachaLabelType)
catalog, _ := s.gacha.SnapshotCatalog()
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, err := s.users.UpdateUser(userId, func(user *store.UserState) {
user.EnsureMaps()
s.autoConvertExpiredMedals(user, catalog, nowMillis)
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
gachaList := make([]*pb.Gacha, 0, len(catalog))
for _, entry := range catalog {
if !matchesGachaLabel(req.GachaLabelType, entry.GachaLabelType) {
continue
}
if entry.GachaLabelType == model.GachaLabelPortalCage || entry.GachaLabelType == model.GachaLabelRecycle {
continue
}
bs := user.Gacha.BannerStates[entry.GachaId]
gachaList = append(gachaList, toProtoGacha(entry, &bs))
}
return &pb.GetGachaListResponse{
Gacha: gachaList,
ConvertedGachaMedal: toProtoConvertedGachaMedal(user.Gacha.ConvertedGachaMedal),
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *GachaServiceServer) autoConvertExpiredMedals(user *store.UserState, catalog []store.GachaCatalogEntry, nowMillis int64) {
for _, entry := range catalog {
if entry.GachaMedalId == 0 || entry.EndDatetime == 0 {
continue
}
if nowMillis < entry.EndDatetime {
continue
}
bs, exists := user.Gacha.BannerStates[entry.GachaId]
if !exists || bs.MedalCount <= 0 {
continue
}
medalInfo, ok := s.handler.MedalInfo[entry.GachaId]
if !ok {
continue
}
conversionRate := medalInfo.ConversionRate
if conversionRate <= 0 {
conversionRate = 1
}
bookmarkCount := bs.MedalCount * conversionRate
user.ConsumableItems[medalInfo.ConsumableItemId] += bookmarkCount
user.Gacha.ConvertedGachaMedal.ConvertedMedalPossession = append(
user.Gacha.ConvertedGachaMedal.ConvertedMedalPossession,
store.ConsumableItemState{
ConsumableItemId: medalInfo.ConsumableItemId,
Count: bookmarkCount,
},
)
originalCount := bs.MedalCount
bs.MedalCount = 0
user.Gacha.BannerStates[entry.GachaId] = bs
log.Printf("[GachaService] auto-converted %d medals for gacha %d -> %d bookmarks (item %d)",
originalCount, entry.GachaId, bookmarkCount, medalInfo.ConsumableItemId)
}
}
func (s *GachaServiceServer) GetGacha(ctx context.Context, req *pb.GetGachaRequest) (*pb.GetGachaResponse, error) {
log.Printf("[GachaService] GetGacha: ids=%v", req.GachaId)
catalog, _ := s.gacha.SnapshotCatalog()
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return nil, fmt.Errorf("snapshot user: %w", err)
}
byId := make(map[int32]*pb.Gacha, len(req.GachaId))
for _, wantedId := range req.GachaId {
for _, entry := range catalog {
if entry.GachaId == wantedId {
bs := user.Gacha.BannerStates[entry.GachaId]
byId[wantedId] = toProtoGacha(entry, &bs)
break
}
}
}
return &pb.GetGachaResponse{
Gacha: byId,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb.DrawResponse, error) {
log.Printf("[GachaService] Draw: gachaId=%d phaseId=%d execCount=%d", req.GachaId, req.GachaPricePhaseId, req.ExecCount)
catalog, _ := s.gacha.SnapshotCatalog()
entry := findCatalogEntry(catalog, req.GachaId)
if entry == nil {
return nil, fmt.Errorf("gacha %d not found", req.GachaId)
}
userId := currentUserId(ctx, s.users, s.sessions)
execCount := req.ExecCount
if execCount <= 0 {
execCount = 1
}
var drawResult *gacha.DrawResult
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
var drawErr error
drawResult, drawErr = s.handler.HandleDraw(user, *entry, req.GachaPricePhaseId, execCount)
if drawErr != nil {
log.Printf("[GachaService] Draw error: %v", drawErr)
drawResult = &gacha.DrawResult{}
}
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
for i, item := range drawResult.Items {
if bonus, ok := drawResult.BonusItems[i]; ok {
log.Printf("[GachaService] drawn[%d]: type=%d id=%d rarity=%d + bonus type=%d id=%d rarity=%d",
i, item.PossessionType, item.PossessionId, item.RarityType,
bonus.PossessionType, bonus.PossessionId, bonus.RarityType)
} else {
log.Printf("[GachaService] drawn[%d]: type=%d id=%d rarity=%d",
i, item.PossessionType, item.PossessionId, item.RarityType)
}
}
gachaResults := make([]*pb.DrawGachaOddsItem, 0, len(drawResult.Items))
dupMap := make(map[int]gacha.DuplicateInfo)
for _, d := range drawResult.DuplicateInfos {
dupMap[d.Index] = d
}
bonusDupMap := make(map[int]gacha.DuplicateInfo)
for _, d := range drawResult.BonusDuplicateInfos {
bonusDupMap[d.Index] = d
}
costumePT := int32(model.PossessionTypeCostume)
weaponPT := int32(model.PossessionTypeWeapon)
isMaterialDraw := model.IsMaterialBanner(entry.GachaLabelType)
ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes))
for _, c := range updatedUser.Costumes {
ownedCostumes[c.CostumeId] = true
}
ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons))
for _, w := range updatedUser.Weapons {
ownedWeapons[w.WeaponId] = true
}
for i, item := range drawResult.Items {
isNew := !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser)
var oddsItem *pb.DrawGachaOddsItem
if isMaterialDraw {
oddsItem = &pb.DrawGachaOddsItem{
GachaItem: &pb.GachaItem{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
Count: 1,
IsNew: isNew,
},
GachaItemBonus: &pb.GachaItem{},
}
} else if bonus, hasBonusWeapon := drawResult.BonusItems[i]; hasBonusWeapon {
oddsItem = &pb.DrawGachaOddsItem{
GachaItem: &pb.GachaItem{
PossessionType: costumePT,
PossessionId: item.PossessionId,
Count: 1,
IsNew: isNew,
},
GachaItemBonus: &pb.GachaItem{
PossessionType: weaponPT,
PossessionId: bonus.PossessionId,
Count: 1,
IsNew: !ownedWeapons[bonus.PossessionId],
},
}
} else {
oddsItem = &pb.DrawGachaOddsItem{
GachaItem: &pb.GachaItem{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
Count: 1,
IsNew: isNew,
},
GachaItemBonus: &pb.GachaItem{},
}
}
if drawResult.MedalBonus > 0 && entry.MedalConsumableItemId != 0 {
oddsItem.MedalBonus = &pb.GachaBonus{
PossessionType: int32(model.PossessionTypeConsumableItem),
PossessionId: entry.MedalConsumableItemId,
Count: 0,
}
}
if dup, ok := dupMap[i]; ok {
applyDuplicationBonus(oddsItem, dup)
}
if bdup, ok := bonusDupMap[i]; ok {
applyDuplicationBonus(oddsItem, bdup)
}
gachaResults = append(gachaResults, oddsItem)
}
var bonuses []*pb.GachaBonus
for _, b := range drawResult.Bonuses {
bonuses = append(bonuses, &pb.GachaBonus{
PossessionType: b.PossessionType,
PossessionId: b.PossessionId,
Count: b.Count,
})
}
bs := updatedUser.Gacha.BannerStates[entry.GachaId]
nextGacha := toProtoGacha(*entry, &bs)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(
userdata.FullClientTableMap(updatedUser),
gachaDiffTables,
))
return &pb.DrawResponse{
NextGacha: nextGacha,
GachaResult: gachaResults,
GachaBonus: bonuses,
MenuGachaBadgeInfo: []*pb.MenuGachaBadgeInfo{},
DiffUserData: diff,
}, nil
}
func (s *GachaServiceServer) ResetBoxGacha(ctx context.Context, req *pb.ResetBoxGachaRequest) (*pb.ResetBoxGachaResponse, error) {
log.Printf("[GachaService] ResetBoxGacha: gachaId=%d", req.GachaId)
catalog, _ := s.gacha.SnapshotCatalog()
entry := findCatalogEntry(catalog, req.GachaId)
if entry == nil {
return nil, fmt.Errorf("gacha %d not found", req.GachaId)
}
userId := currentUserId(ctx, s.users, s.sessions)
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
if resetErr := s.handler.HandleResetBox(user, *entry); resetErr != nil {
log.Printf("[GachaService] ResetBoxGacha error: %v", resetErr)
}
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
bs := updatedUser.Gacha.BannerStates[entry.GachaId]
return &pb.ResetBoxGachaResponse{
Gacha: toProtoGacha(*entry, &bs),
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *GachaServiceServer) GetRewardGacha(ctx context.Context, req *emptypb.Empty) (*pb.GetRewardGachaResponse, error) {
log.Printf("[GachaService] GetRewardGacha")
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return nil, fmt.Errorf("snapshot user: %w", err)
}
maxCount := s.handler.Config.RewardGachaDailyMaxCount
if maxCount <= 0 {
maxCount = model.DefaultDailyDrawLimit
}
todayStart := gametime.StartOfDayMillis()
drawCount := user.Gacha.TodaysCurrentDrawCount
if user.Gacha.LastRewardDrawDate < todayStart {
drawCount = 0
}
return &pb.GetRewardGachaResponse{
Available: drawCount < maxCount,
TodaysCurrentDrawCount: drawCount,
DailyMaxCount: maxCount,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *GachaServiceServer) RewardDraw(ctx context.Context, req *pb.RewardDrawRequest) (*pb.RewardDrawResponse, error) {
log.Printf("[GachaService] RewardDraw: placement=%q reward=%q amount=%q", req.PlacementName, req.RewardName, req.RewardAmount)
userId := currentUserId(ctx, s.users, s.sessions)
var items []gacha.DrawnItem
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
var drawErr error
items, drawErr = s.handler.HandleRewardDraw(user, 1)
if drawErr != nil {
log.Printf("[GachaService] RewardDraw error: %v", drawErr)
}
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes))
for _, c := range updatedUser.Costumes {
ownedCostumes[c.CostumeId] = true
}
ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons))
for _, w := range updatedUser.Weapons {
ownedWeapons[w.WeaponId] = true
}
results := make([]*pb.RewardGachaItem, 0, len(items))
for _, item := range items {
results = append(results, &pb.RewardGachaItem{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
Count: 1,
IsNew: !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser),
})
}
tables := userdata.FullClientTableMap(updatedUser)
diff := userdata.BuildDiffFromTables(tables)
return &pb.RewardDrawResponse{
RewardGachaResult: results,
DiffUserData: diff,
}, nil
}
func findCatalogEntry(catalog []store.GachaCatalogEntry, gachaId int32) *store.GachaCatalogEntry {
for i := range catalog {
if catalog[i].GachaId == gachaId {
return &catalog[i]
}
}
return nil
}
func matchesGachaLabel(labels []int32, label int32) bool {
if len(labels) == 0 {
return true
}
for _, candidate := range labels {
if candidate == label {
return true
}
}
return false
}
func toProtoGacha(entry store.GachaCatalogEntry, bs *store.GachaBannerState) *pb.Gacha {
g := &pb.Gacha{
GachaId: entry.GachaId,
GachaLabelType: entry.GachaLabelType,
GachaModeType: entry.GachaModeType,
GachaAutoResetType: entry.GachaAutoResetType,
GachaAutoResetPeriod: entry.GachaAutoResetPeriod,
NextAutoResetDatetime: safeTimestamp(entry.NextAutoResetDatetime),
GachaUnlockCondition: []*pb.GachaUnlockCondition{{GachaUnlockConditionType: model.GachaUnlockNone, ConditionValue: 0}},
IsUserGachaUnlock: entry.IsUserGachaUnlock,
StartDatetime: safeTimestamp(entry.StartDatetime),
EndDatetime: safeTimestamp(entry.EndDatetime),
RelatedMainQuestChapterId: entry.RelatedMainQuestChapterId,
RelatedEventQuestChapterId: entry.RelatedEventQuestChapterId,
PromotionMovieAssetId: entry.PromotionMovieAssetId,
GachaMedalId: entry.GachaMedalId,
GachaDecorationType: entry.GachaDecorationType,
SortOrder: entry.SortOrder,
IsInactive: entry.IsInactive,
InformationId: entry.InformationId,
}
g.GachaPricePhase = buildProtoPricePhases(entry, bs)
promotionItems := buildProtoPromotionItems(entry)
switch entry.GachaModeType {
case model.GachaModeBox:
boxNumber := int32(1)
if bs != nil && bs.BoxNumber > 0 {
boxNumber = bs.BoxNumber
}
phaseId := int32(0)
if len(entry.PricePhases) > 0 {
phaseId = entry.PricePhases[0].PhaseId
}
g.GachaMode = &pb.Gacha_GachaModeBoxComposition{
GachaModeBoxComposition: &pb.GachaModeBoxComposition{
GachaBoxGroupId: entry.GroupId,
BoxNumber: boxNumber,
CurrentBoxNumber: boxNumber,
NaviCharacterCommentAssetName: "production",
GachaAssetName: entry.BannerAssetName,
GachaPricePhaseId: phaseId,
PromotionGachaOddsItem: promotionItems,
GachaDescriptionTextId: entry.DescriptionTextId,
},
}
case model.GachaModeStepup:
stepNumber := int32(1)
loopCount := int32(0)
if bs != nil {
if bs.StepNumber > 0 {
stepNumber = bs.StepNumber
}
loopCount = bs.LoopCount
}
g.GachaMode = &pb.Gacha_GachaModeStepupComposition{
GachaModeStepupComposition: &pb.GachaModeStepupComposition{
GachaStepGroupId: entry.GroupId,
StepNumber: 1,
CurrentStepNumber: stepNumber,
NaviCharacterCommentAssetName: "production",
GachaAssetName: entry.BannerAssetName,
PromotionGachaOddsItem: promotionItems,
CurrentLoopCount: loopCount,
},
}
default:
g.GachaMode = &pb.Gacha_GachaModeBasic{
GachaModeBasic: &pb.GachaModeBasic{
NaviCharacterCommentAssetName: "production",
GachaAssetName: entry.BannerAssetName,
PromotionGachaOddsItem: promotionItems,
},
}
}
return g
}
func buildProtoPricePhases(entry store.GachaCatalogEntry, bs *store.GachaBannerState) []*pb.GachaPricePhase {
phases := make([]*pb.GachaPricePhase, 0, len(entry.PricePhases))
for _, p := range entry.PricePhases {
isEnabled := true
if entry.GachaModeType == model.GachaModeStepup && bs != nil {
currentStep := bs.StepNumber
if currentStep <= 0 {
currentStep = 1
}
isEnabled = p.StepNumber == currentStep
}
var bonuses []*pb.GachaBonus
for _, b := range p.Bonuses {
bonuses = append(bonuses, &pb.GachaBonus{
PossessionType: b.PossessionType,
PossessionId: b.PossessionId,
Count: b.Count,
})
}
limitExec := p.LimitExecCount
if limitExec <= 0 {
limitExec = 999
}
phases = append(phases, &pb.GachaPricePhase{
GachaPricePhaseId: p.PhaseId,
IsEnabled: isEnabled,
EndDatetime: safeTimestamp(entry.EndDatetime),
PriceType: p.PriceType,
PriceId: p.PriceId,
Price: p.Price,
RegularPrice: p.RegularPrice,
DrawCount: p.DrawCount,
LimitExecCount: limitExec,
EachMaxExecCount: p.DrawCount,
GachaBonus: bonuses,
GachaOddsFixedRarity: &pb.GachaOddsFixedRarity{
FixedRarityTypeLowerLimit: p.FixedRarityMin,
FixedCount: p.FixedCount,
},
GachaBadgeType: model.GachaBadgeTypeNone,
})
}
return phases
}
func buildProtoPromotionItems(entry store.GachaCatalogEntry) []*pb.GachaOddsItem {
if len(entry.PromotionItems) == 0 {
return nil
}
isMaterial := model.IsMaterialBanner(entry.GachaLabelType)
items := make([]*pb.GachaOddsItem, 0, len(entry.PromotionItems))
for i, pi := range entry.PromotionItems {
bonus := &pb.GachaItem{}
if !isMaterial && pi.BonusPossessionType != 0 {
bonus = &pb.GachaItem{
PossessionType: pi.BonusPossessionType,
PossessionId: pi.BonusPossessionId,
Count: 1,
}
}
items = append(items, &pb.GachaOddsItem{
GachaItem: &pb.GachaItem{
PossessionType: pi.PossessionType,
PossessionId: pi.PossessionId,
Count: 1,
PromotionOrder: int32(i + 1),
},
GachaItemBonus: bonus,
MaxDrawableCount: 999,
IsTarget: pi.IsTarget,
})
}
return items
}
func toProtoConvertedGachaMedal(state store.ConvertedGachaMedalState) *pb.ConvertedGachaMedal {
items := make([]*pb.ConsumableItemPossession, 0, len(state.ConvertedMedalPossession))
for _, item := range state.ConvertedMedalPossession {
items = append(items, &pb.ConsumableItemPossession{
ConsumableItemId: item.ConsumableItemId,
Count: item.Count,
})
}
obtain := &pb.ConsumableItemPossession{
ConsumableItemId: 0,
Count: 0,
}
if state.ObtainPossession != nil {
obtain.ConsumableItemId = state.ObtainPossession.ConsumableItemId
obtain.Count = state.ObtainPossession.Count
}
return &pb.ConvertedGachaMedal{
ConvertedMedalPossession: items,
ObtainPossession: obtain,
}
}
func safeTimestamp(unixMillis int64) *timestamppb.Timestamp {
if unixMillis == 0 {
return &timestamppb.Timestamp{Seconds: 0}
}
return timestamppb.New(time.UnixMilli(unixMillis))
}
func applyDuplicationBonus(oddsItem *pb.DrawGachaOddsItem, dup gacha.DuplicateInfo) {
if oddsItem.DuplicationBonusGrade == 0 {
oddsItem.DuplicationBonusGrade = dup.Grade
}
for _, b := range dup.Bonuses {
oddsItem.DuplicationBonus = append(oddsItem.DuplicationBonus, &pb.GachaBonus{
PossessionType: b.PossessionType,
PossessionId: b.PossessionId,
Count: b.Count,
})
}
}
func isOwnedByType(item gacha.DrawnItem, costumes, weapons map[int32]bool, user store.UserState) bool {
switch item.PossessionType {
case int32(model.PossessionTypeCostume):
return costumes[item.PossessionId]
case int32(model.PossessionTypeWeapon):
return weapons[item.PossessionId]
case int32(model.PossessionTypeMaterial):
return user.Materials[item.PossessionId] > 0
case int32(model.PossessionTypeWeaponEnhanced):
return user.ConsumableItems[item.PossessionId] > 0
}
return false
}
+28
View File
@@ -0,0 +1,28 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/userdata"
)
type GameplayServiceServer struct {
pb.UnimplementedGamePlayServiceServer
}
func NewGameplayServiceServer() *GameplayServiceServer {
return &GameplayServiceServer{}
}
func (s *GameplayServiceServer) CheckBeforeGamePlay(ctx context.Context, req *pb.CheckBeforeGamePlayRequest) (*pb.CheckBeforeGamePlayResponse, error) {
log.Printf("[GamePlayService] CheckBeforeGamePlay: tr=%s voiceLang=%d textLang=%d",
req.Tr, req.VoiceClientSystemLanguageTypeId, req.TextClientSystemLanguageTypeId)
return &pb.CheckBeforeGamePlayResponse{
IsExistUnreadPop: false,
MenuGachaBadgeInfo: []*pb.MenuGachaBadgeInfo{},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
+159
View File
@@ -0,0 +1,159 @@
package service
import (
"context"
"fmt"
"log"
"slices"
"sort"
"time"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
type GiftServiceServer struct {
pb.UnimplementedGiftServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewGiftServiceServer(users store.UserRepository, sessions store.SessionRepository) *GiftServiceServer {
return &GiftServiceServer{users: users, sessions: sessions}
}
func (s *GiftServiceServer) ReceiveGift(ctx context.Context, req *pb.ReceiveGiftRequest) (*pb.ReceiveGiftResponse, error) {
log.Printf("[GiftService] ReceiveGift: giftUuids=%d", len(req.UserGiftUuid))
userId := currentUserId(ctx, s.users, s.sessions)
received := make([]string, 0, len(req.UserGiftUuid))
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
nowMillis := gametime.NowMillis()
remaining := make([]store.NotReceivedGiftState, 0, len(user.Gifts.NotReceived))
for _, gift := range user.Gifts.NotReceived {
if slices.Contains(req.UserGiftUuid, gift.UserGiftUuid) {
received = append(received, gift.UserGiftUuid)
user.Gifts.Received = append(user.Gifts.Received, store.ReceivedGiftState{
GiftCommon: gift.GiftCommon,
ReceivedDatetime: nowMillis,
})
continue
}
remaining = append(remaining, gift)
}
user.Gifts.NotReceived = remaining
user.Notifications.GiftNotReceiveCount = int32(len(user.Gifts.NotReceived))
})
if err != nil {
return &pb.ReceiveGiftResponse{
ReceivedGiftUuid: []string{},
ExpiredGiftUuid: []string{},
OverflowGiftUuid: []string{},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
return &pb.ReceiveGiftResponse{
ReceivedGiftUuid: received,
ExpiredGiftUuid: []string{},
OverflowGiftUuid: []string{},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *GiftServiceServer) GetGiftList(ctx context.Context, req *pb.GetGiftListRequest) (*pb.GetGiftListResponse, error) {
log.Printf("[GiftService] GetGiftList: rewardKinds=%v expirationType=%d ascending=%v nextCursor=%d previousCursor=%d getCount=%d",
req.RewardKindType, req.ExpirationType, req.IsAscendingSort, req.NextCursor, req.PreviousCursor, req.GetCount)
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return nil, fmt.Errorf("snapshot user: %w", err)
}
gifts := append([]store.NotReceivedGiftState(nil), user.Gifts.NotReceived...)
sort.Slice(gifts, func(i, j int) bool {
if req.IsAscendingSort {
return gifts[i].ExpirationDatetime < gifts[j].ExpirationDatetime
}
return gifts[i].ExpirationDatetime > gifts[j].ExpirationDatetime
})
if req.GetCount > 0 && len(gifts) > int(req.GetCount) {
gifts = gifts[:req.GetCount]
}
items := make([]*pb.NotReceivedGift, 0, len(gifts))
for _, gift := range gifts {
items = append(items, &pb.NotReceivedGift{
GiftCommon: toProtoGiftCommon(gift.GiftCommon),
ExpirationDatetime: timestampOrNilGift(gift.ExpirationDatetime),
UserGiftUuid: gift.UserGiftUuid,
})
}
return &pb.GetGiftListResponse{
Gift: items,
TotalPageCount: pageCount(len(user.Gifts.NotReceived), int(req.GetCount)),
NextCursor: 0,
PreviousCursor: 0,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *GiftServiceServer) GetGiftReceiveHistoryList(ctx context.Context, req *emptypb.Empty) (*pb.GetGiftReceiveHistoryListResponse, error) {
log.Printf("[GiftService] GetGiftReceiveHistoryList")
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return nil, fmt.Errorf("snapshot user: %w", err)
}
items := make([]*pb.ReceivedGift, 0, len(user.Gifts.Received))
for _, gift := range user.Gifts.Received {
items = append(items, &pb.ReceivedGift{
GiftCommon: toProtoGiftCommon(gift.GiftCommon),
ReceivedDatetime: timestampOrNilGift(gift.ReceivedDatetime),
})
}
return &pb.GetGiftReceiveHistoryListResponse{
Gift: items,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func toProtoGiftCommon(gift store.GiftCommonState) *pb.GiftCommon {
return &pb.GiftCommon{
PossessionType: gift.PossessionType,
PossessionId: gift.PossessionId,
Count: gift.Count,
GrantDatetime: timestampOrNilGift(gift.GrantDatetime),
DescriptionGiftTextId: gift.DescriptionGiftTextId,
EquipmentData: gift.EquipmentData,
}
}
func timestampOrNilGift(unixMillis int64) *timestamppb.Timestamp {
if unixMillis == 0 {
return nil
}
return timestamppb.New(time.UnixMilli(unixMillis))
}
func pageCount(total, pageSize int) int32 {
if total == 0 {
return 0
}
if pageSize <= 0 {
return 1
}
pages := total / pageSize
if total%pageSize != 0 {
pages++
}
return int32(pages)
}
+124
View File
@@ -0,0 +1,124 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
type GimmickServiceServer struct {
pb.UnimplementedGimmickServiceServer
users store.UserRepository
sessions store.SessionRepository
gimmickCatalog *masterdata.GimmickCatalog
}
func NewGimmickServiceServer(users store.UserRepository, sessions store.SessionRepository, gimmickCatalog *masterdata.GimmickCatalog) *GimmickServiceServer {
return &GimmickServiceServer{users: users, sessions: sessions, gimmickCatalog: gimmickCatalog}
}
func (s *GimmickServiceServer) UpdateSequence(ctx context.Context, req *pb.UpdateSequenceRequest) (*pb.UpdateSequenceResponse, error) {
log.Printf("[GimmickService] UpdateSequence: scheduleId=%d sequenceId=%d",
req.GimmickSequenceScheduleId, req.GimmickSequenceId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
key := store.GimmickSequenceKey{
GimmickSequenceScheduleId: req.GimmickSequenceScheduleId,
GimmickSequenceId: req.GimmickSequenceId,
}
sequence := user.Gimmick.Sequences[key]
sequence.Key = key
user.Gimmick.Sequences[key] = sequence
})
return &pb.UpdateSequenceResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserGimmickSequence"})),
}, nil
}
func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *pb.UpdateGimmickProgressRequest) (*pb.UpdateGimmickProgressResponse, error) {
log.Printf("[GimmickService] UpdateGimmickProgress: scheduleId=%d sequenceId=%d gimmickId=%d ornamentIndex=%d progressValueBit=%d flowType=%d",
req.GimmickSequenceScheduleId, req.GimmickSequenceId, req.GimmickId, req.GimmickOrnamentIndex, req.ProgressValueBit, req.FlowType)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
nowMillis := gametime.NowMillis()
progressKey := store.GimmickKey{
GimmickSequenceScheduleId: req.GimmickSequenceScheduleId,
GimmickSequenceId: req.GimmickSequenceId,
GimmickId: req.GimmickId,
}
progress := user.Gimmick.Progress[progressKey]
progress.Key = progressKey
progress.StartDatetime = nowMillis
user.Gimmick.Progress[progressKey] = progress
ornamentKey := store.GimmickOrnamentKey{
GimmickSequenceScheduleId: req.GimmickSequenceScheduleId,
GimmickSequenceId: req.GimmickSequenceId,
GimmickId: req.GimmickId,
GimmickOrnamentIndex: req.GimmickOrnamentIndex,
}
ornament := user.Gimmick.OrnamentProgress[ornamentKey]
ornament.Key = ornamentKey
ornament.ProgressValueBit = req.ProgressValueBit
ornament.BaseDatetime = nowMillis
user.Gimmick.OrnamentProgress[ornamentKey] = ornament
})
return &pb.UpdateGimmickProgressResponse{
GimmickOrnamentReward: []*pb.GimmickReward{},
IsSequenceCleared: false,
GimmickSequenceClearReward: []*pb.GimmickReward{},
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{
"IUserGimmick",
"IUserGimmickOrnamentProgress",
})),
}, nil
}
func (s *GimmickServiceServer) InitSequenceSchedule(ctx context.Context, _ *emptypb.Empty) (*pb.InitSequenceScheduleResponse, error) {
log.Printf("[GimmickService] InitSequenceSchedule")
userId := currentUserId(ctx, s.users, s.sessions)
now := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
added := 0
for _, key := range s.gimmickCatalog.ActiveScheduleKeys(*user, now) {
if _, exists := user.Gimmick.Sequences[key]; !exists {
user.Gimmick.Sequences[key] = store.GimmickSequenceState{Key: key}
added++
}
}
if added > 0 {
log.Printf("[GimmickService] InitSequenceSchedule: added %d sequences (total %d)", added, len(user.Gimmick.Sequences))
}
})
return &pb.InitSequenceScheduleResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), gimmickDiffTables)),
}, nil
}
func (s *GimmickServiceServer) Unlock(ctx context.Context, req *pb.UnlockRequest) (*pb.UnlockResponse, error) {
log.Printf("[GimmickService] Unlock: gimmickKeys=%d", len(req.GimmickKey))
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, item := range req.GimmickKey {
key := store.GimmickKey{
GimmickSequenceScheduleId: item.GimmickSequenceScheduleId,
GimmickSequenceId: item.GimmickSequenceId,
GimmickId: item.GimmickId,
}
unlock := user.Gimmick.Unlocks[key]
unlock.Key = key
unlock.IsUnlocked = true
user.Gimmick.Unlocks[key] = unlock
}
})
return &pb.UnlockResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserGimmickUnlock"})),
}, nil
}
+419
View File
@@ -0,0 +1,419 @@
package service
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
// listBinEntry holds path (')' as segment separator) and size from list.bin; Size is 0 when not present.
type listBinEntry struct {
Path string
Size int64
MD5 string
}
// listBinIndex caches object_id → entry for a revision.
type listBinIndex map[string]listBinEntry
type infoAlias struct {
ToName string
ToRevision string
MD5 string
}
type assetCandidate struct {
Path string
Revision string
Source string
ExpectedMD5 string
}
type listBinLoad struct {
done chan struct{}
idx listBinIndex
ok bool
}
type infoLoad struct {
done chan struct{}
m map[string]infoAlias
}
var (
listBinCache = make(map[string]listBinIndex) // revision → index
listBinInflight = make(map[string]*listBinLoad)
listBinCacheMu sync.RWMutex
infoCache = make(map[string]map[string]infoAlias) // revision → from-name → duplicate target
infoInflight = make(map[string]*infoLoad)
infoCacheMu sync.RWMutex
)
// infoJSONEntry is one entry from assets/revisions/{rev}/info.json (duplicate files: serve to-name when asked for from-name).
type infoJSONEntry struct {
FromName string `json:"from-name"`
ToName string `json:"to-name"`
ToRevision *int `json:"to-revision"`
MD5 string `json:"md5"`
}
// readVarint reads a protobuf varint from b, returns value and number of bytes consumed.
func readVarint(b []byte) (value int, n int) {
for i := 0; i < len(b) && i < 10; i++ {
value |= int(b[i]&0x7f) << (7 * i)
n = i + 1
if b[i]&0x80 == 0 {
return value, n
}
}
return 0, 0
}
func skipProtoField(wireType int, data []byte, offset int) (int, bool) {
switch wireType {
case 0:
_, n := readVarint(data[offset:])
if n == 0 {
return 0, false
}
return offset + n, true
case 1:
if offset+8 > len(data) {
return 0, false
}
return offset + 8, true
case 2:
length, n := readVarint(data[offset:])
if n == 0 || length < 0 || offset+n+length > len(data) {
return 0, false
}
return offset + n + length, true
case 5:
if offset+4 > len(data) {
return 0, false
}
return offset + 4, true
default:
return 0, false
}
}
func parseListBinEntry(data []byte) (objectId string, entry listBinEntry, ok bool) {
i := 0
for i < len(data) {
tag, n := readVarint(data[i:])
if n == 0 {
return "", listBinEntry{}, false
}
i += n
fieldNum := tag >> 3
wireType := tag & 0x7
switch fieldNum {
case 3: // path
if wireType != 2 {
return "", listBinEntry{}, false
}
length, vn := readVarint(data[i:])
if vn == 0 || length < 0 || i+vn+length > len(data) {
return "", listBinEntry{}, false
}
entry.Path = string(data[i+vn : i+vn+length])
i += vn + length
case 4: // size
if wireType != 0 {
return "", listBinEntry{}, false
}
size, vn := readVarint(data[i:])
if vn == 0 {
return "", listBinEntry{}, false
}
if size >= 256 {
entry.Size = int64(size)
}
i += vn
case 10: // md5
if wireType != 2 {
return "", listBinEntry{}, false
}
length, vn := readVarint(data[i:])
if vn == 0 || length < 0 || i+vn+length > len(data) {
return "", listBinEntry{}, false
}
entry.MD5 = string(data[i+vn : i+vn+length])
i += vn + length
case 11: // object_id
if wireType != 2 {
return "", listBinEntry{}, false
}
length, vn := readVarint(data[i:])
if vn == 0 || length <= 0 || i+vn+length > len(data) {
return "", listBinEntry{}, false
}
objectId = string(data[i+vn : i+vn+length])
i += vn + length
default:
next, ok := skipProtoField(wireType, data, i)
if !ok {
return "", listBinEntry{}, false
}
i = next
}
}
if objectId == "" || entry.Path == "" {
return "", listBinEntry{}, false
}
return objectId, entry, true
}
// parseListBin reads list.bin and builds object_id (6-byte string) → entry (path, size, md5).
// The file is a protobuf message with repeated nested entry messages, so we parse each entry
// boundary first instead of doing a flat scan across the whole file.
func parseListBin(data []byte) listBinIndex {
idx := make(listBinIndex)
i := 0
for i < len(data) {
tag, n := readVarint(data[i:])
if n == 0 {
break
}
i += n
wireType := tag & 0x7
if wireType == 2 {
length, vn := readVarint(data[i:])
if vn == 0 || length < 0 || i+vn+length > len(data) {
break
}
entryBytes := data[i+vn : i+vn+length]
objectId, entry, ok := parseListBinEntry(entryBytes)
if ok {
idx[objectId] = entry
i += vn + length
continue
}
}
next, ok := skipProtoField(wireType, data, i)
if !ok {
break
}
i = next
}
return idx
}
func loadListBinIndex(revision string) (listBinIndex, bool) {
listBinCacheMu.RLock()
idx, ok := listBinCache[revision]
listBinCacheMu.RUnlock()
if ok {
return idx, true
}
listBinCacheMu.Lock()
if idx, ok := listBinCache[revision]; ok {
listBinCacheMu.Unlock()
return idx, true
}
if load := listBinInflight[revision]; load != nil {
listBinCacheMu.Unlock()
<-load.done
return load.idx, load.ok
}
load := &listBinLoad{done: make(chan struct{})}
listBinInflight[revision] = load
listBinCacheMu.Unlock()
filePath := filepath.Join("assets", "revisions", revision, "list.bin")
data, err := os.ReadFile(filePath)
if err != nil {
listBinCacheMu.Lock()
delete(listBinInflight, revision)
close(load.done)
listBinCacheMu.Unlock()
return nil, false
}
idx = parseListBin(data)
load.idx = idx
load.ok = true
listBinCacheMu.Lock()
listBinCache[revision] = idx
delete(listBinInflight, revision)
close(load.done)
listBinCacheMu.Unlock()
return idx, true
}
func loadInfoIndex(revision string) map[string]infoAlias {
infoCacheMu.RLock()
m, ok := infoCache[revision]
infoCacheMu.RUnlock()
if ok {
return m
}
infoCacheMu.Lock()
if m, ok := infoCache[revision]; ok {
infoCacheMu.Unlock()
return m
}
if load := infoInflight[revision]; load != nil {
infoCacheMu.Unlock()
<-load.done
return load.m
}
load := &infoLoad{done: make(chan struct{})}
infoInflight[revision] = load
infoCacheMu.Unlock()
filePath := filepath.Join("assets", "revisions", revision, "info.json")
data, err := os.ReadFile(filePath)
if err != nil {
infoCacheMu.Lock()
infoCache[revision] = nil
delete(infoInflight, revision)
close(load.done)
infoCacheMu.Unlock()
return nil
}
var entries []infoJSONEntry
if err := json.Unmarshal(data, &entries); err != nil {
infoCacheMu.Lock()
infoCache[revision] = nil
delete(infoInflight, revision)
close(load.done)
infoCacheMu.Unlock()
return nil
}
m = make(map[string]infoAlias)
for _, e := range entries {
if e.FromName != "" && e.ToName != "" {
aliasRevision := revision
if e.ToRevision != nil {
aliasRevision = strconv.Itoa(*e.ToRevision)
}
m[e.FromName] = infoAlias{
ToName: e.ToName,
ToRevision: aliasRevision,
MD5: e.MD5,
}
}
}
load.m = m
infoCacheMu.Lock()
infoCache[revision] = m
delete(infoInflight, revision)
close(load.done)
infoCacheMu.Unlock()
return m
}
func pathStrToFullPaths(revision, assetType, pathStr string) []string {
fsPath := strings.ReplaceAll(pathStr, ")", "/")
if strings.Contains(fsPath, "..") || filepath.IsAbs(fsPath) || strings.HasPrefix(fsPath, "/") {
return nil
}
fsPath = filepath.Clean(fsPath)
if strings.Contains(fsPath, "..") {
return nil
}
// Prefer "global" (en) when list.bin points to ja/ko: try en first, then original.
var pathStrs []string
if strings.Contains(pathStr, ")ja)") {
pathStrs = append(pathStrs, strings.ReplaceAll(pathStr, ")ja)", ")en)"))
}
if strings.Contains(pathStr, ")ko)") {
pathStrs = append(pathStrs, strings.ReplaceAll(pathStr, ")ko)", ")en)"))
}
pathStrs = append(pathStrs, pathStr)
base := filepath.Join("assets", "revisions", revision)
var out []string
seen := make(map[string]bool)
for _, p := range pathStrs {
cleaned := filepath.Clean(strings.ReplaceAll(p, ")", "/"))
if seen[cleaned] {
continue
}
seen[cleaned] = true
switch assetType {
case "assetbundle":
out = append(out, filepath.Join(base, "assetbundle", cleaned+".assetbundle"))
case "resources":
out = append(out, filepath.Join(base, "resources", cleaned))
default:
return nil
}
}
return out
}
func appendUniqueCandidate(candidates []assetCandidate, seen map[string]bool, candidate assetCandidate) []assetCandidate {
key := candidate.Revision + ":" + candidate.Path
if seen[key] {
return candidates
}
seen[key] = true
return append(candidates, candidate)
}
func duplicateCandidatePath(candidate assetCandidate, assetType, targetRevision, targetBaseName string) string {
root := filepath.Join("assets", "revisions", candidate.Revision, assetType)
rel, err := filepath.Rel(root, candidate.Path)
if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return ""
}
return filepath.Join("assets", "revisions", targetRevision, assetType, filepath.Dir(rel), targetBaseName)
}
// objectIdToFilePathCandidates returns candidate file paths for the object: list.bin path, locale fallbacks
// (ja/ko -> en), then info.json duplicate mappings (from-name -> to-name, possibly in a different revision).
// Callers should try each path until one exists on disk.
func objectIdToFilePathCandidates(revision, assetType, objectId string) (candidates []assetCandidate, size int64, ok bool) {
idx, ok := loadListBinIndex(revision)
if !ok || idx == nil {
return nil, 0, false
}
entry, ok := idx[objectId]
if !ok || entry.Path == "" {
return nil, 0, false
}
paths := pathStrToFullPaths(revision, assetType, entry.Path)
if len(paths) == 0 {
return nil, 0, false
}
seen := make(map[string]bool)
for _, path := range paths {
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
Path: path,
Revision: revision,
Source: "list.bin",
ExpectedMD5: entry.MD5,
})
}
// Add paths from info.json: when requested file is a "from-name" (duplicate not included), serve "to-name" instead.
infoIndex := loadInfoIndex(revision)
if len(infoIndex) > 0 {
for _, c := range candidates {
alias, ok := infoIndex[filepath.Base(c.Path)]
if !ok || alias.ToName == "" {
continue
}
alt := duplicateCandidatePath(c, assetType, alias.ToRevision, alias.ToName)
if alt == "" {
continue
}
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
Path: alt,
Revision: alias.ToRevision,
Source: "info.json redirect",
ExpectedMD5: alias.MD5,
})
}
}
return candidates, entry.Size, true
}
+72
View File
@@ -0,0 +1,72 @@
package service
import (
"context"
"fmt"
"log"
"time"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
type LoginBonusServiceServer struct {
pb.UnimplementedLoginBonusServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.LoginBonusCatalog
}
func NewLoginBonusServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.LoginBonusCatalog) *LoginBonusServiceServer {
return &LoginBonusServiceServer{users: users, sessions: sessions, catalog: catalog}
}
func (s *LoginBonusServiceServer) ReceiveStamp(ctx context.Context, req *emptypb.Empty) (*pb.ReceiveStampResponse, error) {
log.Printf("[LoginBonusService] ReceiveStamp")
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
now := gametime.NowMillis()
nextStamp := user.LoginBonus.CurrentStampNumber + 1
reward, ok := s.catalog.LookupStampReward(
user.LoginBonus.LoginBonusId,
user.LoginBonus.CurrentPageNumber,
nextStamp,
)
if !ok {
log.Fatalf("[LoginBonusService] no reward found for bonusId=%d page=%d stamp=%d",
user.LoginBonus.LoginBonusId, user.LoginBonus.CurrentPageNumber, nextStamp)
}
log.Printf("[LoginBonusService] stamp %d -> possType=%d possId=%d count=%d (-> gift box)",
nextStamp, reward.PossessionType, reward.PossessionId, reward.Count)
user.Gifts.NotReceived = append(user.Gifts.NotReceived, store.NotReceivedGiftState{
GiftCommon: store.GiftCommonState{
PossessionType: reward.PossessionType,
PossessionId: reward.PossessionId,
Count: reward.Count,
GrantDatetime: now,
},
ExpirationDatetime: now + int64(30*24*time.Hour/time.Millisecond),
UserGiftUuid: fmt.Sprintf("login-bonus-%d-%d", userId, nextStamp),
})
user.Notifications.GiftNotReceiveCount = int32(len(user.Gifts.NotReceived))
user.LoginBonus.CurrentStampNumber = nextStamp
user.LoginBonus.LatestRewardReceiveDatetime = now
user.LoginBonus.LatestVersion = now
})
diff := userdata.BuildDiffFromTables(userdata.SelectTables(
userdata.FullClientTableMap(user),
[]string{"IUserLoginBonus"},
))
setCommonResponseTrailers(ctx, diff, false)
return &pb.ReceiveStampResponse{DiffUserData: diff}, nil
}
+80
View File
@@ -0,0 +1,80 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
var materialDiffTables = []string{
"IUserMaterial",
"IUserConsumableItem",
}
type MaterialServiceServer struct {
pb.UnimplementedMaterialServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.MaterialCatalog
config *masterdata.GameConfig
}
func NewMaterialServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.MaterialCatalog, config *masterdata.GameConfig) *MaterialServiceServer {
return &MaterialServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
}
func (s *MaterialServiceServer) Sell(ctx context.Context, req *pb.MaterialSellRequest) (*pb.MaterialSellResponse, error) {
log.Printf("[MaterialService] Sell: %d item(s)", len(req.MaterialPossession))
userId := currentUserId(ctx, s.users, s.sessions)
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"})
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
totalGold := int32(0)
for _, item := range req.MaterialPossession {
mat, ok := s.catalog.All[item.MaterialId]
if !ok {
log.Printf("[MaterialService] Sell: unknown materialId=%d, skipping", item.MaterialId)
continue
}
cur := user.Materials[item.MaterialId]
if cur < item.Count {
log.Printf("[MaterialService] Sell: insufficient materialId=%d have=%d need=%d", item.MaterialId, cur, item.Count)
continue
}
user.Materials[item.MaterialId] -= item.Count
if user.Materials[item.MaterialId] <= 0 {
delete(user.Materials, item.MaterialId)
}
gold := mat.SellPrice * item.Count
totalGold += gold
log.Printf("[MaterialService] Sell: materialId=%d x%d -> %d gold", item.MaterialId, item.Count, gold)
}
if totalGold > 0 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold
log.Printf("[MaterialService] Sell: total gold +%d", totalGold)
}
})
if err != nil {
return nil, fmt.Errorf("material sell: %w", err)
}
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), materialDiffTables)
diff := tracker.Apply(snapshot, tables)
return &pb.MaterialSellResponse{
DiffUserData: diff,
}, nil
}
+38
View File
@@ -0,0 +1,38 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type MissionServiceServer struct {
pb.UnimplementedMissionServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewMissionServiceServer(users store.UserRepository, sessions store.SessionRepository) *MissionServiceServer {
return &MissionServiceServer{users: users, sessions: sessions}
}
func (s *MissionServiceServer) UpdateMissionProgress(ctx context.Context, req *pb.UpdateMissionProgressRequest) (*pb.UpdateMissionProgressResponse, error) {
log.Printf("[MissionService] UpdateMissionProgress: cage=%v pictureBook=%v", req.CageMeasurableValues, req.PictureBookMeasurableValues)
userId := currentUserId(ctx, s.users, s.sessions)
snapshot, err := s.users.SnapshotUser(userId)
if err != nil {
return nil, fmt.Errorf("snapshot user: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserMission"}))
return &pb.UpdateMissionProgressResponse{
DiffUserData: diff,
}, nil
}
+45
View File
@@ -0,0 +1,45 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type MovieServiceServer struct {
pb.UnimplementedMovieServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewMovieServiceServer(users store.UserRepository, sessions store.SessionRepository) *MovieServiceServer {
return &MovieServiceServer{users: users, sessions: sessions}
}
func (s *MovieServiceServer) SaveViewedMovie(ctx context.Context, req *pb.SaveViewedMovieRequest) (*pb.SaveViewedMovieResponse, error) {
log.Printf("[MovieService] SaveViewedMovie: movieIds=%v", req.MovieId)
userId := currentUserId(ctx, s.users, s.sessions)
now := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, mid := range req.MovieId {
user.ViewedMovies[mid] = now
}
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserMovie"}))
return &pb.SaveViewedMovieResponse{
DiffUserData: diff,
}, nil
}
+40
View File
@@ -0,0 +1,40 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type NaviCutInServiceServer struct {
pb.UnimplementedNaviCutInServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewNaviCutInServiceServer(users store.UserRepository, sessions store.SessionRepository) *NaviCutInServiceServer {
return &NaviCutInServiceServer{users: users, sessions: sessions}
}
func (s *NaviCutInServiceServer) RegisterPlayed(ctx context.Context, req *pb.RegisterPlayedRequest) (*pb.RegisterPlayedResponse, error) {
log.Printf("[NaviCutInService] RegisterPlayed: naviCutId=%d", req.NaviCutId)
userId := currentUserId(ctx, s.users, s.sessions)
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
user.NaviCutInPlayed[req.NaviCutId] = true
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserNaviCutIn"}))
return &pb.RegisterPlayedResponse{
DiffUserData: diff,
}, nil
}
+42
View File
@@ -0,0 +1,42 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
type NotificationServiceServer struct {
pb.UnimplementedNotificationServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewNotificationServiceServer(users store.UserRepository, sessions store.SessionRepository) *NotificationServiceServer {
return &NotificationServiceServer{users: users, sessions: sessions}
}
func (s *NotificationServiceServer) GetHeaderNotification(ctx context.Context, req *emptypb.Empty) (*pb.GetHeaderNotificationResponse, error) {
log.Printf("[NotificationService] GetHeaderNotification")
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return &pb.GetHeaderNotificationResponse{
GiftNotReceiveCount: 0,
FriendRequestReceiveCount: 0,
IsExistUnreadInformation: false,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
return &pb.GetHeaderNotificationResponse{
GiftNotReceiveCount: int32(len(user.Gifts.NotReceived)),
FriendRequestReceiveCount: user.Notifications.FriendRequestReceiveCount,
IsExistUnreadInformation: user.Notifications.IsExistUnreadInformation,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
+457
View File
@@ -0,0 +1,457 @@
package service
import (
"bytes"
"crypto/md5"
"encoding/hex"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
const termsVersionMarker = "###123###"
const privacyVersionMarker = "###123###"
const informationPage = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lunar Tear</title>
<style>
body { margin:0; padding:40px 20px; font-family:"Noto Sans",sans-serif;
background:#0a0a0f; color:#d4cfc6; text-align:center; }
h1 { font-size:1.4em; letter-spacing:.15em; color:#e8e0d0; margin-bottom:4px; }
.sub { font-size:.75em; color:#888; margin-bottom:32px; }
.sep { width:60px; border:none; border-top:1px solid #333; margin:24px auto; }
p { font-size:.85em; line-height:1.6; color:#999; max-width:360px; margin:0 auto 12px; }
a { color:#a0c4e8; text-decoration:none; }
</style>
</head>
<body>
<h1>LUNAR TEAR</h1>
<div class="sub">Private Preservation Server</div>
<hr class="sep">
<p>A community effort to keep NieR Re[in]carnation playable after official service ended.</p>
<p>This server is not affiliated with or endorsed by SQUARE ENIX or Applibot.</p>
<hr class="sep">
<p style="font-size:.7em;color:#666;">&copy; SQUARE ENIX / Applibot — All game assets belong to their respective owners.</p>
</body>
</html>`
// resourcesURLOriginal is the base URL embedded in list.bin; must be replaced with same-length (43 bytes) when rewriting.
const resourcesURLOriginal = "https://resources.app.nierreincarnation.com"
type OctoHTTPServer struct {
mux *http.ServeMux
ResourcesBaseURL string // if non-empty and exactly 43 chars, list.bin is rewritten to use this base for asset URLs
revisions *revisionTracker
resolver *assetResolver
}
func staticPageLanguage(path string) string {
parts := strings.Split(path, "/")
for i := 0; i+1 < len(parts); i++ {
if parts[i] == "static" && parts[i+1] != "" {
return parts[i+1]
}
}
return "unknown"
}
func renderStaticTermsPage(title, language, version string) string {
return "<html><head><title>" + title + "</title></head><body><h1>" + title +
"</h1><p>Language: " + language + "</p><p>Version: " + version + "</p></body></html>"
}
// countResponseWriter wraps http.ResponseWriter and counts bytes written.
type countResponseWriter struct {
http.ResponseWriter
n int64
}
type fileMD5Entry struct {
size int64
modTime int64
md5 string
}
var (
fileMD5Cache = make(map[string]fileMD5Entry)
fileMD5CacheMu sync.RWMutex
)
func (c *countResponseWriter) Write(p []byte) (int, error) {
n, err := c.ResponseWriter.Write(p)
c.n += int64(n)
return n, err
}
func fileMD5Hex(path string, info os.FileInfo) (string, error) {
modTime := info.ModTime().UnixNano()
fileMD5CacheMu.RLock()
cached, ok := fileMD5Cache[path]
fileMD5CacheMu.RUnlock()
if ok && cached.size == info.Size() && cached.modTime == modTime {
return cached.md5, nil
}
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := md5.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
sum := hex.EncodeToString(h.Sum(nil))
fileMD5CacheMu.Lock()
fileMD5Cache[path] = fileMD5Entry{
size: info.Size(),
modTime: modTime,
md5: sum,
}
fileMD5CacheMu.Unlock()
return sum, nil
}
func NewOctoHTTPServer(resourcesBaseURL string) *OctoHTTPServer {
s := &OctoHTTPServer{
mux: http.NewServeMux(),
ResourcesBaseURL: resourcesBaseURL,
revisions: newRevisionTracker(),
resolver: newAssetResolver(),
}
s.mux.HandleFunc("/", s.handleAll)
return s
}
func (s *OctoHTTPServer) Handler() http.Handler {
return s.mux
}
func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
isAssetRequest := strings.Contains(path, "/unso-")
isMasterDataRequest := strings.Contains(path, "/assets/release/") && strings.Contains(path, "database.bin")
if !isAssetRequest && !isMasterDataRequest {
log.Printf("[HTTP] %s %s (Host: %s)", r.Method, r.URL.String(), r.Host)
for k, v := range r.Header {
log.Printf("[HTTP] %s: %s", k, v)
}
}
// Octo v2 API — asset bundle management
if strings.HasPrefix(path, "/v2/") {
s.handleOctoV2(w, r, path)
return
}
// Octo v1 list: /v1/list/{version}/{revision} — same list.bin as v2, keyed by revision
if strings.HasPrefix(path, "/v1/list/") {
s.serveOctoV1List(w, r, path)
return
}
// Game web API requests
if strings.Contains(path, "/web/") || strings.Contains(r.Host, "web.app.nierreincarnation") {
s.handleWebAPI(w, r, path)
return
}
// Master data download (should not be reached if version matches)
if strings.HasPrefix(path, "/master-data/") {
log.Printf("[HTTP] Master data request for path: %s — returning empty", path)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", "0")
w.WriteHeader(200)
return
}
// /assets/release/{version}/database.bin.e — master data (HEAD/GET), same as MariesWonderland
if strings.Contains(path, "/assets/release/") && strings.Contains(path, "database.bin") {
s.serveDatabaseBinE(w, r, path)
return
}
// Asset bundle requests (from list.bin URLs: .../unso-{v}-{type}/{o}?generation=...&alt=media)
if strings.Contains(path, "/unso-") {
s.serveUnsoAsset(w, r, path)
return
}
// In-game information / news page
if strings.Contains(path, "/information") {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
w.Write([]byte(informationPage))
return
}
// Log request body for debugging Octo protocol
if r.Body != nil {
body := make([]byte, 4096)
n, _ := r.Body.Read(body)
if n > 0 {
log.Printf("[HTTP] body (%d bytes): %x", n, body[:n])
if n < 256 {
log.Printf("[HTTP] body (ascii): %s", string(body[:n]))
}
}
}
log.Printf("[HTTP] >>> UNHANDLED REQUEST: %s %s — returning empty 200", r.Method, path)
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(200)
w.Write([]byte{})
}
func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path string) {
log.Printf("[OctoV2] %s %s", r.Method, path)
// /v2/pub/a/{appId}/v/{version}/list/{offset} — resource listing
if strings.Contains(path, "/list/") {
parts := strings.Split(path, "/")
if len(parts) > 0 {
requestedRevision := parts[len(parts)-1]
if requestedRevision != "" {
revision := "0"
filePath := "assets/revisions/0/list.bin"
if requestedRevision != revision {
log.Printf("[OctoV2] Resource list request revision=%s canonicalized to revision=%s", requestedRevision, revision)
}
log.Printf("[OctoV2] Resource list request — serving %s (requested_revision=%s canonical_revision=%s)", filePath, requestedRevision, revision)
s.revisions.Remember(r.RemoteAddr, revision)
go s.resolver.Prewarm(revision)
s.serveListBin(w, filePath)
return
}
}
log.Printf("[OctoV2] Resource list request without revision segment — returning empty protobuf")
w.Header().Set("Content-Type", "application/x-protobuf")
w.WriteHeader(http.StatusOK)
return
}
// /v2/pub/a/{appId}/v/{version}/info — DB info
if strings.Contains(path, "/info") {
log.Printf("[OctoV2] Info request — returning empty protobuf")
w.Header().Set("Content-Type", "application/x-protobuf")
w.WriteHeader(200)
return
}
log.Printf("[OctoV2] Unknown endpoint: %s — returning empty protobuf", path)
w.Header().Set("Content-Type", "application/x-protobuf")
w.WriteHeader(200)
}
// serveOctoV1List handles GET /v1/list/{version}/{revision} — serves assets/revisions/{revision}/list.bin.
func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, path string) {
parts := strings.Split(strings.Trim(path, "/"), "/")
// ["v1", "list", "300116832", "0"] -> revision = last segment
requestedRevision := "0"
if len(parts) >= 4 {
requestedRevision = parts[len(parts)-1]
}
revision := "0"
filePath := "assets/revisions/0/list.bin"
if requestedRevision != revision {
log.Printf("[OctoV1] list request revision=%s canonicalized to revision=%s", requestedRevision, revision)
}
log.Printf("[OctoV1] %s %s — serving %s (requested_revision=%s canonical_revision=%s)", r.Method, path, filePath, requestedRevision, revision)
s.revisions.Remember(r.RemoteAddr, revision)
go s.resolver.Prewarm(revision)
s.serveListBin(w, filePath)
}
// serveUnsoAsset serves asset bundle or resource for URLs like /resource-bundle-server/unso-{version}-{type}/{object_id}.
func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, path string) {
parts := strings.Split(strings.Trim(path, "/"), "/")
var segment, objectId string
for i, p := range parts {
if strings.HasPrefix(p, "unso-") && i+1 < len(parts) {
segment = p
objectId = parts[i+1]
break
}
}
if segment == "" || objectId == "" {
log.Printf("[HTTP] Asset request malformed: %s", path)
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusNotFound)
return
}
// segment = "unso-200116832-assetbundle" -> type = last part after "-"
segParts := strings.Split(segment, "-")
if len(segParts) < 2 {
log.Printf("[HTTP] Asset request segment malformed: %s", segment)
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusNotFound)
return
}
assetType := segParts[len(segParts)-1] // "assetbundle" or "resources"
if assetType != "assetbundle" && assetType != "resources" {
log.Printf("[HTTP] Asset request unknown type: %s", assetType)
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusNotFound)
return
}
activeRevision := s.revisions.Active(r.RemoteAddr)
resolution, ok := s.resolver.Resolve(objectId, assetType, activeRevision)
if !ok {
log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s) no candidates", path, objectId, assetType, activeRevision)
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusNotFound)
return
}
baseDir := filepath.Join("assets", "revisions")
var triedPaths []string
var md5Mismatches []string
for _, candidate := range resolution.Candidates {
rel, err := filepath.Rel(baseDir, candidate.Path)
if err != nil || strings.Contains(rel, "..") || filepath.IsAbs(rel) {
continue
}
triedPaths = append(triedPaths, candidate.Revision+":"+candidate.Path+" ["+candidate.Source+"]")
f, err := os.Open(candidate.Path)
if err != nil {
continue
}
info, err := f.Stat()
if err != nil {
f.Close()
continue
}
if info.IsDir() {
f.Close()
continue
}
// Only validate size when list.bin gave a plausible file size (>= 256); small values are often wrong (e.g. different proto field).
if resolution.ListSize >= 256 && info.Size() != resolution.ListSize {
f.Close()
continue
}
if candidate.ExpectedMD5 != "" {
actualMD5, err := fileMD5Hex(candidate.Path, info)
if err != nil {
log.Printf("[HTTP] Asset md5 read failed: %s err=%v", candidate.Path, err)
f.Close()
continue
}
if !strings.EqualFold(actualMD5, candidate.ExpectedMD5) {
md5Mismatches = append(md5Mismatches, candidate.Revision+":"+candidate.Path+" ["+candidate.Source+"] expected="+candidate.ExpectedMD5+" actual="+actualMD5)
log.Printf("[HTTP] Asset md5 mismatch: object_id=%s type=%s path=%s expected=%s actual=%s active_revision=%s list_revision=%s resolved_revision=%s source=%s", objectId, assetType, candidate.Path, candidate.ExpectedMD5, actualMD5, resolution.ActiveRevision, resolution.ListRevision, candidate.Revision, candidate.Source)
f.Close()
continue
}
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
cw := &countResponseWriter{ResponseWriter: w}
http.ServeContent(cw, r, filepath.Base(candidate.Path), info.ModTime(), f)
return
}
if len(md5Mismatches) > 0 {
log.Printf("[HTTP] Asset md5 mismatches: object_id=%s type=%s active_revision=%s list_revision=%s mismatches=%v", objectId, assetType, resolution.ActiveRevision, resolution.ListRevision, md5Mismatches)
}
log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s list_revision=%s) tried paths: %v", path, objectId, assetType, resolution.ActiveRevision, resolution.ListRevision, triedPaths)
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusNotFound)
}
// serveListBin reads list.bin from filePath, optionally rewrites the resource base URL to s.ResourcesBaseURL
// (must be exactly 43 bytes to preserve protobuf layout), and writes the result to w.
func (s *OctoHTTPServer) serveListBin(w http.ResponseWriter, filePath string) {
data, err := os.ReadFile(filePath)
if err != nil {
log.Printf("[Octo] list.bin read error: %v", err)
http.Error(w, "list not found", http.StatusNotFound)
return
}
orig := []byte(resourcesURLOriginal)
if s.ResourcesBaseURL != "" {
if len(s.ResourcesBaseURL) != len(orig) {
log.Printf("[Octo] resources-base-url length is %d, need %d — serving list.bin unchanged", len(s.ResourcesBaseURL), len(orig))
} else {
repl := []byte(s.ResourcesBaseURL)
if idx := bytes.Index(data, orig); idx >= 0 {
copy(data[idx:], repl)
log.Printf("[Octo] list.bin: rewrote resource base URL to %s", s.ResourcesBaseURL)
}
}
}
w.Header().Set("Content-Type", "application/x-protobuf")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.WriteHeader(http.StatusOK)
w.Write(data)
}
// serveDatabaseBinE serves MasterMemory database: /assets/release/{version}/database.bin.e
// -> assets/release/{version}.bin.e (or assets/release/database.bin.e fallback).
func (s *OctoHTTPServer) serveDatabaseBinE(w http.ResponseWriter, r *http.Request, path string) {
parts := strings.Split(path, "/")
var version string
for i, p := range parts {
if p == "release" && i+1 < len(parts) {
version = parts[i+1]
break
}
}
filePath := "assets/release/database.bin.e"
if version != "" {
vPath := "assets/release/" + version + ".bin.e"
if _, err := os.Stat(vPath); err == nil {
filePath = vPath
}
}
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeFile(w, r, filePath)
}
func (s *OctoHTTPServer) handleWebAPI(w http.ResponseWriter, r *http.Request, path string) {
log.Printf("[WebAPI] Serving: %s", path)
if strings.Contains(path, "database.bin") {
s.serveDatabaseBinE(w, r, path)
return
}
if strings.Contains(path, "termsofuse") {
language := staticPageLanguage(path)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(200)
w.Write([]byte(renderStaticTermsPage("Terms of Service", language, termsVersionMarker)))
return
}
if strings.Contains(path, "privacy") {
language := staticPageLanguage(path)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
w.Write([]byte(renderStaticTermsPage("Privacy Policy", language, privacyVersionMarker)))
return
}
if strings.Contains(path, "maintenance") {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
w.Write([]byte(`<!DOCTYPE html><html><body></body></html>`))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
w.Write([]byte(`<!DOCTYPE html><html><body></body></html>`))
}
+47
View File
@@ -0,0 +1,47 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type OmikujiServiceServer struct {
pb.UnimplementedOmikujiServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.OmikujiCatalog
}
func NewOmikujiServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.OmikujiCatalog) *OmikujiServiceServer {
return &OmikujiServiceServer{users: users, sessions: sessions, catalog: catalog}
}
func (s *OmikujiServiceServer) OmikujiDraw(ctx context.Context, req *pb.OmikujiDrawRequest) (*pb.OmikujiDrawResponse, error) {
log.Printf("[OmikujiService] OmikujiDraw: omikujiId=%d", req.OmikujiId)
userId := currentUserId(ctx, s.users, s.sessions)
now := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
user.DrawnOmikuji[req.OmikujiId] = now
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserOmikuji"}))
return &pb.OmikujiDrawResponse{
OmikujiResultAssetId: s.catalog.LookupAssetId(req.OmikujiId),
OmikujiItem: []*pb.OmikujiItem{},
DiffUserData: diff,
}, nil
}
+196
View File
@@ -0,0 +1,196 @@
package service
import (
"context"
"fmt"
"log"
"math/rand"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
const partsMaxLevel = int32(15)
var partsDiffTables = []string{
"IUserParts",
"IUserConsumableItem",
}
type PartsServiceServer struct {
pb.UnimplementedPartsServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.PartsCatalog
config *masterdata.GameConfig
}
func NewPartsServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.PartsCatalog, config *masterdata.GameConfig) *PartsServiceServer {
return &PartsServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
}
func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest) (*pb.PartsSellResponse, error) {
log.Printf("[PartsService] Sell: %d part(s)", len(req.UserPartsUuid))
userId := currentUserId(ctx, s.users, s.sessions)
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserParts", oldUser, userdata.SortedPartsRecords, []string{"userId", "userPartsUuid"})
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
totalGold := int32(0)
for _, uuid := range req.UserPartsUuid {
part, ok := user.Parts[uuid]
if !ok {
log.Printf("[PartsService] Sell: uuid=%s not found, skipping", uuid)
continue
}
if part.IsProtected {
log.Printf("[PartsService] Sell: uuid=%s is protected, skipping", uuid)
continue
}
partDef, ok := s.catalog.PartsById[part.PartsId]
if !ok {
log.Printf("[PartsService] Sell: partsId=%d not in catalog, skipping", part.PartsId)
continue
}
sellFunc, ok := s.catalog.SellPriceByRarity[partDef.RarityType]
if !ok {
log.Printf("[PartsService] Sell: no sell price func for rarity=%d, skipping", partDef.RarityType)
continue
}
gold := sellFunc.Evaluate(part.Level)
totalGold += gold
delete(user.Parts, uuid)
log.Printf("[PartsService] Sell: uuid=%s partsId=%d level=%d -> %d gold", uuid, part.PartsId, part.Level, gold)
}
if totalGold > 0 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold
log.Printf("[PartsService] Sell: total gold +%d", totalGold)
}
})
if err != nil {
return nil, fmt.Errorf("parts sell: %w", err)
}
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), partsDiffTables)
diff := tracker.Apply(snapshot, tables)
return &pb.PartsSellResponse{
DiffUserData: diff,
}, nil
}
func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRequest) (*pb.PartsEnhanceResponse, error) {
log.Printf("[PartsService] Enhance: uuid=%s", req.UserPartsUuid)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
isSuccess := false
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
part, ok := user.Parts[req.UserPartsUuid]
if !ok {
log.Printf("[PartsService] Enhance: part uuid=%s not found", req.UserPartsUuid)
return
}
if part.Level >= partsMaxLevel {
log.Printf("[PartsService] Enhance: part uuid=%s already at max level %d", req.UserPartsUuid, part.Level)
return
}
partDef, ok := s.catalog.PartsById[part.PartsId]
if !ok {
log.Printf("[PartsService] Enhance: part master id=%d not found", part.PartsId)
return
}
rarity, ok := s.catalog.RarityByRarityType[partDef.RarityType]
if !ok {
log.Printf("[PartsService] Enhance: rarity type=%d not found", partDef.RarityType)
return
}
goldCost := int32(0)
if prices, ok := s.catalog.PriceByGroupAndLevel[rarity.PartsLevelUpPriceGroupId]; ok {
goldCost = prices[part.Level]
}
currentGold := user.ConsumableItems[s.config.ConsumableItemIdForGold]
if currentGold < goldCost {
log.Printf("[PartsService] Enhance: insufficient gold have=%d need=%d", currentGold, goldCost)
return
}
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
successRate := int32(1000)
if rates, ok := s.catalog.RateByGroupAndLevel[rarity.PartsLevelUpRateGroupId]; ok {
if r, ok := rates[part.Level]; ok {
successRate = r
}
}
if rand.Intn(1000) < int(successRate) {
part.Level++
isSuccess = true
log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰, cost=%d gold)",
part.PartsId, part.Level-1, part.Level, successRate, goldCost)
} else {
log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰, cost=%d gold)",
part.PartsId, part.Level, successRate, goldCost)
}
part.LatestVersion = nowMillis
user.Parts[req.UserPartsUuid] = part
})
if err != nil {
return nil, fmt.Errorf("parts enhance: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, partsDiffTables))
return &pb.PartsEnhanceResponse{
IsSuccess: isSuccess,
DiffUserData: diff,
}, nil
}
func (s *PartsServiceServer) ReplacePreset(ctx context.Context, req *pb.PartsReplacePresetRequest) (*pb.PartsReplacePresetResponse, error) {
log.Printf("[PartsService] ReplacePreset: preset=%d uuids=[%s, %s, %s]",
req.UserPartsPresetNumber, req.UserPartsUuid01, req.UserPartsUuid02, req.UserPartsUuid03)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
preset := user.PartsPresets[req.UserPartsPresetNumber]
preset.UserPartsPresetNumber = req.UserPartsPresetNumber
preset.UserPartsUuid01 = req.UserPartsUuid01
preset.UserPartsUuid02 = req.UserPartsUuid02
preset.UserPartsUuid03 = req.UserPartsUuid03
preset.LatestVersion = nowMillis
user.PartsPresets[req.UserPartsPresetNumber] = preset
})
if err != nil {
return nil, fmt.Errorf("parts replace preset: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserPartsPreset"}))
return &pb.PartsReplacePresetResponse{
DiffUserData: diff,
}, nil
}
+40
View File
@@ -0,0 +1,40 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type PortalCageServiceServer struct {
pb.UnimplementedPortalCageServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewPortalCageServiceServer(users store.UserRepository, sessions store.SessionRepository) *PortalCageServiceServer {
return &PortalCageServiceServer{users: users, sessions: sessions}
}
func (s *PortalCageServiceServer) UpdatePortalCageSceneProgress(ctx context.Context, req *pb.UpdatePortalCageSceneProgressRequest) (*pb.UpdatePortalCageSceneProgressResponse, error) {
log.Printf("[PortalCageService] UpdatePortalCageSceneProgress: portalCageSceneId=%d", req.PortalCageSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
now := gametime.NowMillis()
user.PortalCageStatus.IsCurrentProgress = true
user.PortalCageStatus.LatestVersion = now
})
tables := userdata.SelectTables(
userdata.FullClientTableMap(user),
[]string{"IUserPortalCageStatus"},
)
return &pb.UpdatePortalCageSceneProgressResponse{
DiffUserData: userdata.BuildDiffFromTablesOrdered(tables, []string{"IUserPortalCageStatus"}),
}, nil
}
+403
View File
@@ -0,0 +1,403 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
type BigHuntServiceServer struct {
pb.UnimplementedBigHuntServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.BigHuntCatalog
engine *questflow.QuestHandler
}
func NewBigHuntServiceServer(
users store.UserRepository,
sessions store.SessionRepository,
catalog *masterdata.BigHuntCatalog,
engine *questflow.QuestHandler,
) *BigHuntServiceServer {
return &BigHuntServiceServer{users: users, sessions: sessions, catalog: catalog, engine: engine}
}
var bigHuntDiffTables = []string{
"IUserBigHuntProgressStatus",
"IUserBigHuntMaxScore",
"IUserBigHuntStatus",
"IUserBigHuntScheduleMaxScore",
"IUserBigHuntWeeklyMaxScore",
"IUserBigHuntWeeklyStatus",
}
func buildBigHuntDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData {
tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames)
return userdata.BuildDiffFromTablesOrdered(tables, tableNames)
}
func (s *BigHuntServiceServer) StartBigHuntQuest(ctx context.Context, req *pb.StartBigHuntQuestRequest) (*pb.StartBigHuntQuestResponse, error) {
log.Printf("[BigHuntService] StartBigHuntQuest: bossQuestId=%d questId=%d deckNumber=%d isDryRun=%v",
req.BigHuntBossQuestId, req.BigHuntQuestId, req.UserDeckNumber, req.IsDryRun)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
bhQuest, ok := s.catalog.QuestById[req.BigHuntQuestId]
if !ok {
log.Printf("[BigHuntService] StartBigHuntQuest: unknown bigHuntQuestId=%d", req.BigHuntQuestId)
}
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
if ok {
s.engine.HandleBigHuntQuestStart(user, bhQuest.QuestId, req.UserDeckNumber, nowMillis)
}
user.BigHuntProgress = store.BigHuntProgress{
CurrentBigHuntBossQuestId: req.BigHuntBossQuestId,
CurrentBigHuntQuestId: req.BigHuntQuestId,
CurrentQuestSceneId: 0,
IsDryRun: req.IsDryRun,
LatestVersion: nowMillis,
}
user.BigHuntDeckNumber = req.UserDeckNumber
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
st.DailyChallengeCount++
st.LatestChallengeDatetime = nowMillis
st.LatestVersion = nowMillis
user.BigHuntStatuses[req.BigHuntBossQuestId] = st
})
return &pb.StartBigHuntQuestResponse{
DiffUserData: buildBigHuntDiff(user, append([]string{"IUserQuest"}, bigHuntDiffTables...)),
}, nil
}
func (s *BigHuntServiceServer) UpdateBigHuntQuestSceneProgress(ctx context.Context, req *pb.UpdateBigHuntQuestSceneProgressRequest) (*pb.UpdateBigHuntQuestSceneProgressResponse, error) {
log.Printf("[BigHuntService] UpdateBigHuntQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.BigHuntProgress.CurrentQuestSceneId = req.QuestSceneId
user.BigHuntProgress.LatestVersion = nowMillis
})
return &pb.UpdateBigHuntQuestSceneProgressResponse{
DiffUserData: buildBigHuntDiff(user, []string{"IUserBigHuntProgressStatus"}),
}, nil
}
func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.FinishBigHuntQuestRequest) (*pb.FinishBigHuntQuestResponse, error) {
log.Printf("[BigHuntService] FinishBigHuntQuest: bossQuestId=%d questId=%d isRetired=%v",
req.BigHuntBossQuestId, req.BigHuntQuestId, req.IsRetired)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
bhQuest := s.catalog.QuestById[req.BigHuntQuestId]
bossQuest := s.catalog.BossQuestById[req.BigHuntBossQuestId]
boss := s.catalog.BossByBossId[bossQuest.BigHuntBossId]
var scoreInfo *pb.BigHuntScoreInfo
var scoreRewards []*pb.BigHuntReward
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleBigHuntQuestFinish(user, bhQuest.QuestId, req.IsRetired, false, nowMillis)
if req.IsRetired || user.BigHuntProgress.IsDryRun {
user.BigHuntProgress = store.BigHuntProgress{LatestVersion: nowMillis}
return
}
detail := user.BigHuntBattleDetail
totalDamage := detail.TotalDamage
baseScore := totalDamage
difficultyBonusPermil := int32(0)
if coeff, ok := s.catalog.ScoreCoefficients[bhQuest.BigHuntQuestScoreCoefficientId]; ok {
difficultyBonusPermil = coeff
}
aliveBonusPermil := int32(500)
maxComboBonusPermil := int32(0)
if detail.MaxComboCount >= 100 {
maxComboBonusPermil = 300
} else if detail.MaxComboCount >= 50 {
maxComboBonusPermil = 200
} else if detail.MaxComboCount >= 20 {
maxComboBonusPermil = 100
}
userScore := baseScore * int64(1000+difficultyBonusPermil+aliveBonusPermil+maxComboBonusPermil) / 1000
isHighScore := false
oldMaxBoss := user.BigHuntMaxScores[bossQuest.BigHuntBossId]
oldMax := oldMaxBoss.MaxScore
if userScore > oldMax {
isHighScore = true
user.BigHuntMaxScores[bossQuest.BigHuntBossId] = store.BigHuntMaxScore{
MaxScore: userScore,
MaxScoreUpdateDatetime: nowMillis,
LatestVersion: nowMillis,
}
}
schedKey := store.BigHuntScheduleScoreKey{
BigHuntScheduleId: s.catalog.ActiveScheduleId,
BigHuntBossId: bossQuest.BigHuntBossId,
}
oldSchedMax := user.BigHuntScheduleMaxScores[schedKey].MaxScore
if userScore > oldSchedMax {
user.BigHuntScheduleMaxScores[schedKey] = store.BigHuntScheduleMaxScore{
MaxScore: userScore,
MaxScoreUpdateDatetime: nowMillis,
LatestVersion: nowMillis,
}
}
weeklyVersion := gametime.WeeklyVersion(nowMillis)
weekKey := store.BigHuntWeeklyScoreKey{
BigHuntWeeklyVersion: weeklyVersion,
AttributeType: boss.AttributeType,
}
oldWeeklyMax := user.BigHuntWeeklyMaxScores[weekKey].MaxScore
if userScore > oldWeeklyMax {
user.BigHuntWeeklyMaxScores[weekKey] = store.BigHuntWeeklyMaxScore{
MaxScore: userScore,
LatestVersion: nowMillis,
}
}
assetGradeIconId := s.catalog.ResolveGradeIconId(bossQuest.BigHuntBossId, userScore)
scoreInfo = &pb.BigHuntScoreInfo{
UserScore: userScore,
IsHighScore: isHighScore,
TotalDamage: totalDamage,
BaseScore: baseScore,
DifficultyBonusPermil: difficultyBonusPermil,
AliveBonusPermil: aliveBonusPermil,
MaxComboBonusPermil: maxComboBonusPermil,
AssetGradeIconId: assetGradeIconId,
}
if isHighScore {
rewardGroupId := s.catalog.ResolveActiveScoreRewardGroupId(
bossQuest.BigHuntScoreRewardGroupScheduleId, nowMillis)
if rewardGroupId > 0 {
newItems := s.catalog.CollectNewRewards(rewardGroupId, oldMax, userScore)
for _, item := range newItems {
s.engine.Granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis)
scoreRewards = append(scoreRewards, &pb.BigHuntReward{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
Count: item.Count,
})
}
}
}
user.BigHuntProgress = store.BigHuntProgress{LatestVersion: nowMillis}
user.BigHuntBattleBinary = nil
user.BigHuntBattleDetail = store.BigHuntBattleDetail{}
})
if scoreInfo == nil {
scoreInfo = &pb.BigHuntScoreInfo{}
}
if scoreRewards == nil {
scoreRewards = []*pb.BigHuntReward{}
}
return &pb.FinishBigHuntQuestResponse{
ScoreInfo: scoreInfo,
ScoreReward: scoreRewards,
BattleReport: &pb.BigHuntBattleReport{
BattleReportWave: []*pb.BigHuntBattleReportWave{},
},
DiffUserData: buildBigHuntDiff(user, append([]string{
"IUserQuest",
"IUserConsumableItem",
"IUserMaterial",
}, bigHuntDiffTables...)),
}, nil
}
func (s *BigHuntServiceServer) RestartBigHuntQuest(ctx context.Context, req *pb.RestartBigHuntQuestRequest) (*pb.RestartBigHuntQuestResponse, error) {
log.Printf("[BigHuntService] RestartBigHuntQuest: bossQuestId=%d questId=%d", req.BigHuntBossQuestId, req.BigHuntQuestId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
bhQuest := s.catalog.QuestById[req.BigHuntQuestId]
var battleBinary []byte
var deckNumber int32
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleBigHuntQuestStart(user, bhQuest.QuestId, user.BigHuntDeckNumber, nowMillis)
user.BigHuntProgress.CurrentQuestSceneId = 0
user.BigHuntProgress.LatestVersion = nowMillis
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
st.DailyChallengeCount++
st.LatestChallengeDatetime = nowMillis
st.LatestVersion = nowMillis
user.BigHuntStatuses[req.BigHuntBossQuestId] = st
battleBinary = user.BigHuntBattleBinary
deckNumber = user.BigHuntDeckNumber
})
return &pb.RestartBigHuntQuestResponse{
BattleBinary: battleBinary,
DeckNumber: deckNumber,
DiffUserData: buildBigHuntDiff(user, append([]string{"IUserQuest"}, bigHuntDiffTables...)),
}, nil
}
func (s *BigHuntServiceServer) SkipBigHuntQuest(ctx context.Context, req *pb.SkipBigHuntQuestRequest) (*pb.SkipBigHuntQuestResponse, error) {
log.Printf("[BigHuntService] SkipBigHuntQuest: bossQuestId=%d skipCount=%d", req.BigHuntBossQuestId, req.SkipCount)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
st.DailyChallengeCount += req.SkipCount
st.LatestChallengeDatetime = nowMillis
st.LatestVersion = nowMillis
user.BigHuntStatuses[req.BigHuntBossQuestId] = st
})
return &pb.SkipBigHuntQuestResponse{
ScoreReward: []*pb.BigHuntReward{},
DiffUserData: buildBigHuntDiff(user, bigHuntDiffTables),
}, nil
}
func (s *BigHuntServiceServer) SaveBigHuntBattleInfo(ctx context.Context, req *pb.SaveBigHuntBattleInfoRequest) (*pb.SaveBigHuntBattleInfoResponse, error) {
log.Printf("[BigHuntService] SaveBigHuntBattleInfo: elapsedFrames=%d", req.ElapsedFrameCount)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
var totalDamage int64
if req.BigHuntBattleDetail != nil {
for _, ci := range req.BigHuntBattleDetail.CostumeBattleInfo {
if ci != nil {
totalDamage += ci.TotalDamage
}
}
}
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.BigHuntBattleBinary = req.BattleBinary
if req.BigHuntBattleDetail != nil {
user.BigHuntBattleDetail = store.BigHuntBattleDetail{
DeckType: req.BigHuntBattleDetail.DeckType,
UserTripleDeckNumber: req.BigHuntBattleDetail.UserTripleDeckNumber,
BossKnockDownCount: req.BigHuntBattleDetail.BossKnockDownCount,
MaxComboCount: req.BigHuntBattleDetail.MaxComboCount,
TotalDamage: totalDamage,
}
}
user.BigHuntProgress.LatestVersion = nowMillis
})
return &pb.SaveBigHuntBattleInfoResponse{
DiffUserData: buildBigHuntDiff(user, []string{"IUserBigHuntProgressStatus"}),
}, nil
}
func (s *BigHuntServiceServer) GetBigHuntTopData(ctx context.Context, _ *emptypb.Empty) (*pb.GetBigHuntTopDataResponse, error) {
log.Printf("[BigHuntService] GetBigHuntTopData")
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.SnapshotUser(userId)
nowMillis := gametime.NowMillis()
weeklyVersion := gametime.WeeklyVersion(nowMillis)
var weeklyScoreResults []*pb.WeeklyScoreResult
for _, boss := range s.catalog.BossByBossId {
key := store.BigHuntWeeklyScoreKey{
BigHuntWeeklyVersion: weeklyVersion,
AttributeType: boss.AttributeType,
}
ws := user.BigHuntWeeklyMaxScores[key]
gradeIconId := s.catalog.ResolveGradeIconId(boss.BigHuntBossId, ws.MaxScore)
weeklyScoreResults = append(weeklyScoreResults, &pb.WeeklyScoreResult{
AttributeType: boss.AttributeType,
BeforeMaxScore: ws.MaxScore,
CurrentMaxScore: ws.MaxScore,
BeforeAssetGradeIconId: gradeIconId,
CurrentAssetGradeIconId: gradeIconId,
AfterMaxScore: ws.MaxScore,
AfterAssetGradeIconId: gradeIconId,
})
}
ws := user.BigHuntWeeklyStatuses[weeklyVersion]
weeklyRewards := s.resolveWeeklyRewards(user, weeklyVersion, nowMillis)
lastWeekVersion := weeklyVersion - 7*24*60*60*1000
lastWeekRewards := s.resolveWeeklyRewards(user, lastWeekVersion, nowMillis)
return &pb.GetBigHuntTopDataResponse{
WeeklyScoreResult: weeklyScoreResults,
WeeklyScoreReward: weeklyRewards,
IsReceivedWeeklyScoreReward: ws.IsReceivedWeeklyReward,
LastWeekWeeklyScoreReward: lastWeekRewards,
DiffUserData: buildBigHuntDiff(user, bigHuntDiffTables),
}, nil
}
func (s *BigHuntServiceServer) resolveWeeklyRewards(user store.UserState, weeklyVersion, nowMillis int64) []*pb.BigHuntReward {
var rewards []*pb.BigHuntReward
for _, boss := range s.catalog.BossByBossId {
rewardKey := masterdata.BigHuntWeeklyRewardKey{
ScheduleId: 1,
AttributeType: boss.AttributeType,
}
rewardGroupId := s.catalog.ResolveActiveWeeklyRewardGroupId(rewardKey, nowMillis)
if rewardGroupId == 0 {
continue
}
weekKey := store.BigHuntWeeklyScoreKey{
BigHuntWeeklyVersion: weeklyVersion,
AttributeType: boss.AttributeType,
}
maxScore := user.BigHuntWeeklyMaxScores[weekKey].MaxScore
for _, item := range s.catalog.CollectNewRewards(rewardGroupId, 0, maxScore) {
rewards = append(rewards, &pb.BigHuntReward{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
Count: item.Count,
})
}
}
if rewards == nil {
rewards = []*pb.BigHuntReward{}
}
return rewards
}
+149
View File
@@ -0,0 +1,149 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
func (s *QuestServiceServer) StartEventQuest(ctx context.Context, req *pb.StartEventQuestRequest) (*pb.StartEventQuestResponse, error) {
log.Printf("[QuestService] StartEventQuest: chapterId=%d questId=%d isBattleOnly=%v", req.EventQuestChapterId, req.QuestId, req.IsBattleOnly)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleEventQuestStart(user, req.EventQuestChapterId, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis)
})
drops := s.engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{
QuestSceneId: d.QuestSceneId,
BattleDropCategoryId: d.BattleDropCategoryId,
BattleDropEffectId: 1,
}
}
return &pb.StartEventQuestResponse{
BattleDropReward: pbDrops,
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserStatus",
"IUserQuest",
"IUserQuestMission",
"IUserEventQuestProgressStatus",
}),
}, nil
}
func (s *QuestServiceServer) FinishEventQuest(ctx context.Context, req *pb.FinishEventQuestRequest) (*pb.FinishEventQuestResponse, error) {
log.Printf("[QuestService] FinishEventQuest: chapterId=%d questId=%d isRetired=%v isAnnihilated=%v", req.EventQuestChapterId, req.QuestId, req.IsRetired, req.IsAnnihilated)
nowMillis := gametime.NowMillis()
userId := currentUserId(ctx, s.users, s.sessions)
var outcome questflow.FinishOutcome
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
outcome = s.engine.HandleEventQuestFinish(user, req.EventQuestChapterId, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis)
})
return &pb.FinishEventQuestResponse{
DropReward: toProtoRewards(outcome.DropRewards),
FirstClearReward: toProtoRewards(outcome.FirstClearRewards),
MissionClearReward: toProtoRewards(outcome.MissionClearRewards),
MissionClearCompleteReward: toProtoRewards(outcome.MissionClearCompleteRewards),
AutoOrbitResult: []*pb.QuestReward{},
IsBigWin: outcome.IsBigWin,
BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds,
UserStatusCampaignReward: []*pb.QuestReward{},
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserQuest",
"IUserQuestMission",
"IUserEventQuestProgressStatus",
"IUserStatus",
"IUserGem",
"IUserCharacter",
"IUserCostume",
"IUserCostumeActiveSkill",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserWeaponNote",
"IUserWeaponStory",
"IUserCompanion",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
"IUserParts",
"IUserPartsGroupNote",
}),
}, nil
}
func (s *QuestServiceServer) RestartEventQuest(ctx context.Context, req *pb.RestartEventQuestRequest) (*pb.RestartEventQuestResponse, error) {
log.Printf("[QuestService] RestartEventQuest: chapterId=%d questId=%d", req.EventQuestChapterId, req.QuestId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleEventQuestRestart(user, req.EventQuestChapterId, req.QuestId, gametime.NowMillis())
})
return &pb.RestartEventQuestResponse{
BattleDropReward: []*pb.BattleDropReward{},
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserQuest",
"IUserQuestMission",
"IUserEventQuestProgressStatus",
}),
}, nil
}
func (s *QuestServiceServer) UpdateEventQuestSceneProgress(ctx context.Context, req *pb.UpdateEventQuestSceneProgressRequest) (*pb.UpdateEventQuestSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateEventQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleEventQuestSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
})
return &pb.UpdateEventQuestSceneProgressResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserEventQuestProgressStatus",
"IUserCharacter",
"IUserCostume",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserCompanion",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
"IUserParts",
"IUserPartsGroupNote",
}),
}, nil
}
const defaultGuerrillaFreeOpenMinutes = int32(60)
func (s *QuestServiceServer) StartGuerrillaFreeOpen(ctx context.Context, req *emptypb.Empty) (*pb.StartGuerrillaFreeOpenResponse, error) {
log.Printf("[QuestService] StartGuerrillaFreeOpen")
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.GuerrillaFreeOpen.StartDatetime = nowMillis
user.GuerrillaFreeOpen.OpenMinutes = defaultGuerrillaFreeOpenMinutes
user.GuerrillaFreeOpen.DailyOpenedCount++
user.GuerrillaFreeOpen.LatestVersion = nowMillis
})
return &pb.StartGuerrillaFreeOpenResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{"IUserEventQuestGuerrillaFreeOpen"}),
}, nil
}
+138
View File
@@ -0,0 +1,138 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store"
)
func (s *QuestServiceServer) StartExtraQuest(ctx context.Context, req *pb.StartExtraQuestRequest) (*pb.StartExtraQuestResponse, error) {
log.Printf("[QuestService] StartExtraQuest: questId=%d deckNumber=%d", req.QuestId, req.UserDeckNumber)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleExtraQuestStart(user, req.QuestId, req.UserDeckNumber, nowMillis)
})
drops := s.engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{
QuestSceneId: d.QuestSceneId,
BattleDropCategoryId: d.BattleDropCategoryId,
BattleDropEffectId: 1,
}
}
return &pb.StartExtraQuestResponse{
BattleDropReward: pbDrops,
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserStatus",
"IUserQuest",
"IUserQuestMission",
"IUserExtraQuestProgressStatus",
}),
}, nil
}
func (s *QuestServiceServer) FinishExtraQuest(ctx context.Context, req *pb.FinishExtraQuestRequest) (*pb.FinishExtraQuestResponse, error) {
log.Printf("[QuestService] FinishExtraQuest: questId=%d isRetired=%v isAnnihilated=%v", req.QuestId, req.IsRetired, req.IsAnnihilated)
nowMillis := gametime.NowMillis()
userId := currentUserId(ctx, s.users, s.sessions)
var outcome questflow.FinishOutcome
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
outcome = s.engine.HandleExtraQuestFinish(user, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis)
})
return &pb.FinishExtraQuestResponse{
DropReward: toProtoRewards(outcome.DropRewards),
FirstClearReward: toProtoRewards(outcome.FirstClearRewards),
MissionClearReward: toProtoRewards(outcome.MissionClearRewards),
MissionClearCompleteReward: toProtoRewards(outcome.MissionClearCompleteRewards),
IsBigWin: outcome.IsBigWin,
BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds,
UserStatusCampaignReward: []*pb.QuestReward{},
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserQuest",
"IUserQuestMission",
"IUserExtraQuestProgressStatus",
"IUserStatus",
"IUserGem",
"IUserCharacter",
"IUserCostume",
"IUserCostumeActiveSkill",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserWeaponNote",
"IUserWeaponStory",
"IUserCompanion",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
"IUserParts",
"IUserPartsGroupNote",
}),
}, nil
}
func (s *QuestServiceServer) RestartExtraQuest(ctx context.Context, req *pb.RestartExtraQuestRequest) (*pb.RestartExtraQuestResponse, error) {
log.Printf("[QuestService] RestartExtraQuest: questId=%d", req.QuestId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleExtraQuestRestart(user, req.QuestId, gametime.NowMillis())
})
drops := s.engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{
QuestSceneId: d.QuestSceneId,
BattleDropCategoryId: d.BattleDropCategoryId,
BattleDropEffectId: 1,
}
}
return &pb.RestartExtraQuestResponse{
BattleDropReward: pbDrops,
DeckNumber: user.Quests[req.QuestId].UserDeckNumber,
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserQuest",
"IUserQuestMission",
"IUserExtraQuestProgressStatus",
}),
}, nil
}
func (s *QuestServiceServer) UpdateExtraQuestSceneProgress(ctx context.Context, req *pb.UpdateExtraQuestSceneProgressRequest) (*pb.UpdateExtraQuestSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateExtraQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleExtraQuestSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
})
return &pb.UpdateExtraQuestSceneProgressResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserExtraQuestProgressStatus",
"IUserCharacter",
"IUserCostume",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserCompanion",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
"IUserParts",
"IUserPartsGroupNote",
}),
}, nil
}
+366
View File
@@ -0,0 +1,366 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
type QuestServiceServer struct {
pb.UnimplementedQuestServiceServer
users store.UserRepository
sessions store.SessionRepository
engine *questflow.QuestHandler
}
func NewQuestServiceServer(users store.UserRepository, sessions store.SessionRepository, engine *questflow.QuestHandler) *QuestServiceServer {
if engine == nil {
panic("quest handler is required")
}
return &QuestServiceServer{users: users, sessions: sessions, engine: engine}
}
func buildSelectedQuestDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData {
tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames)
return userdata.BuildDiffFromTablesOrdered(tables, tableNames)
}
func (s *QuestServiceServer) UpdateMainFlowSceneProgress(ctx context.Context, req *pb.UpdateMainFlowSceneProgressRequest) (*pb.UpdateMainFlowSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateMainFlowSceneProgress: questSceneId=%d", req.QuestSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleMainFlowSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
})
return &pb.UpdateMainFlowSceneProgressResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserMainQuestFlowStatus",
"IUserMainQuestMainFlowStatus",
"IUserMainQuestProgressStatus",
"IUserMainQuestSeasonRoute",
"IUserPortalCageStatus",
"IUserSideStoryQuestSceneProgressStatus",
"IUserQuest",
"IUserCharacter",
"IUserCostume",
"IUserCostumeActiveSkill",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserWeaponNote",
"IUserWeaponStory",
"IUserCompanion",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
"IUserParts",
"IUserPartsGroupNote",
}),
}, nil
}
func (s *QuestServiceServer) UpdateReplayFlowSceneProgress(ctx context.Context, req *pb.UpdateReplayFlowSceneProgressRequest) (*pb.UpdateReplayFlowSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateReplayFlowSceneProgress: questSceneId=%d", req.QuestSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleReplayFlowSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
})
return &pb.UpdateReplayFlowSceneProgressResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserMainQuestFlowStatus",
"IUserMainQuestReplayFlowStatus",
}),
}, nil
}
func (s *QuestServiceServer) UpdateMainQuestSceneProgress(ctx context.Context, req *pb.UpdateMainQuestSceneProgressRequest) (*pb.UpdateMainQuestSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateMainQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleMainQuestSceneProgress(user, req.QuestSceneId)
})
return &pb.UpdateMainQuestSceneProgressResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserStatus",
"IUserCharacter",
"IUserQuest",
"IUserQuestMission",
"IUserMainQuestFlowStatus",
"IUserMainQuestMainFlowStatus",
"IUserMainQuestProgressStatus",
}),
}, nil
}
func (s *QuestServiceServer) StartMainQuest(ctx context.Context, req *pb.StartMainQuestRequest) (*pb.StartMainQuestResponse, error) {
log.Printf("[QuestService] StartMainQuest: %+v", req)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
if req.IsReplayFlow {
s.engine.HandleQuestStartReplay(user, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis)
} else {
s.engine.HandleQuestStart(user, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis)
}
})
drops := s.engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{
QuestSceneId: d.QuestSceneId,
BattleDropCategoryId: d.BattleDropCategoryId,
BattleDropEffectId: 1,
}
}
return &pb.StartMainQuestResponse{
BattleDropReward: pbDrops,
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserStatus",
"IUserQuest",
"IUserQuestMission",
"IUserMainQuestFlowStatus",
"IUserMainQuestMainFlowStatus",
"IUserMainQuestProgressStatus",
"IUserMainQuestSeasonRoute",
"IUserMainQuestReplayFlowStatus",
}),
}, nil
}
func toProtoRewards(grants []questflow.RewardGrant) []*pb.QuestReward {
if len(grants) == 0 {
return []*pb.QuestReward{}
}
out := make([]*pb.QuestReward, len(grants))
for i, g := range grants {
out[i] = &pb.QuestReward{
PossessionType: int32(g.PossessionType),
PossessionId: g.PossessionId,
Count: g.Count,
}
}
return out
}
func (s *QuestServiceServer) FinishMainQuest(ctx context.Context, req *pb.FinishMainQuestRequest) (*pb.FinishMainQuestResponse, error) {
log.Printf("[QuestService] FinishMainQuest: questId=%d isMainFlow=%v isRetired=%v isAnnihilated=%v storySkipType=%d",
req.QuestId, req.IsMainFlow, req.IsRetired, req.IsAnnihilated, req.StorySkipType)
nowMillis := gametime.NowMillis()
userId := currentUserId(ctx, s.users, s.sessions)
var outcome questflow.FinishOutcome
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
outcome = s.engine.HandleQuestFinish(user, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis)
})
return &pb.FinishMainQuestResponse{
DropReward: toProtoRewards(outcome.DropRewards),
FirstClearReward: toProtoRewards(outcome.FirstClearRewards),
MissionClearReward: toProtoRewards(outcome.MissionClearRewards),
MissionClearCompleteReward: toProtoRewards(outcome.MissionClearCompleteRewards),
AutoOrbitResult: []*pb.QuestReward{},
IsBigWin: outcome.IsBigWin,
BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds,
ReplayFlowFirstClearReward: toProtoRewards(outcome.ReplayFlowFirstClearRewards),
UserStatusCampaignReward: []*pb.QuestReward{},
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserQuest",
"IUserQuestMission",
"IUserMainQuestFlowStatus",
"IUserMainQuestMainFlowStatus",
"IUserMainQuestProgressStatus",
"IUserMainQuestSeasonRoute",
"IUserMainQuestReplayFlowStatus",
"IUserStatus",
"IUserGem",
"IUserCharacter",
"IUserCostume",
"IUserCostumeActiveSkill",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserWeaponNote",
"IUserWeaponStory",
"IUserCompanion",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
"IUserParts",
"IUserPartsGroupNote",
}),
}, nil
}
func (s *QuestServiceServer) RestartMainQuest(ctx context.Context, req *pb.RestartMainQuestRequest) (*pb.RestartMainQuestResponse, error) {
log.Printf("[QuestService] RestartMainQuest: questId=%d isMainFlow=%v", req.QuestId, req.IsMainFlow)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleQuestRestart(user, req.QuestId, gametime.NowMillis())
})
drops := s.engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{
QuestSceneId: d.QuestSceneId,
BattleDropCategoryId: d.BattleDropCategoryId,
BattleDropEffectId: 1,
}
}
return &pb.RestartMainQuestResponse{
BattleDropReward: pbDrops,
DeckNumber: user.Quests[req.QuestId].UserDeckNumber,
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserStatus",
"IUserQuest",
"IUserQuestMission",
"IUserMainQuestFlowStatus",
"IUserMainQuestMainFlowStatus",
"IUserMainQuestProgressStatus",
"IUserMainQuestSeasonRoute",
}),
}, nil
}
func (s *QuestServiceServer) FinishAutoOrbit(ctx context.Context, req *emptypb.Empty) (*pb.FinishAutoOrbitResponse, error) {
log.Printf("[QuestService] FinishAutoOrbit")
return &pb.FinishAutoOrbitResponse{
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *QuestServiceServer) SkipQuest(ctx context.Context, req *pb.SkipQuestRequest) (*pb.SkipQuestResponse, error) {
log.Printf("[QuestService] SkipQuest: questId=%d skipCount=%d useEffectItems=%d", req.QuestId, req.SkipCount, len(req.UseEffectItem))
nowMillis := gametime.NowMillis()
userId := currentUserId(ctx, s.users, s.sessions)
var outcome questflow.FinishOutcome
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, item := range req.UseEffectItem {
log.Printf("[QuestService] SkipQuest UseEffectItem: consumableItemId=%d count=%d", item.ConsumableItemId, item.Count)
user.ConsumableItems[item.ConsumableItemId] -= item.Count
if user.ConsumableItems[item.ConsumableItemId] < 0 {
user.ConsumableItems[item.ConsumableItemId] = 0
}
}
outcome = s.engine.HandleQuestSkip(user, req.QuestId, req.SkipCount, nowMillis)
})
return &pb.SkipQuestResponse{
DropReward: toProtoRewards(outcome.DropRewards),
UserStatusCampaignReward: []*pb.QuestReward{},
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserQuest",
"IUserStatus",
"IUserConsumableItem",
"IUserMaterial",
"IUserParts",
"IUserPartsGroupNote",
"IUserCharacter",
"IUserCostume",
}),
}, nil
}
func (s *QuestServiceServer) SetRoute(ctx context.Context, req *pb.SetRouteRequest) (*pb.SetRouteResponse, error) {
log.Printf("[QuestService] SetRoute: mainQuestRouteId=%d", req.MainQuestRouteId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.MainQuest.CurrentMainQuestRouteId = req.MainQuestRouteId
if seasonId, ok := s.engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok {
user.MainQuest.MainQuestSeasonId = seasonId
}
now := gametime.NowMillis()
user.PortalCageStatus.IsCurrentProgress = false
user.PortalCageStatus.LatestVersion = now
})
return &pb.SetRouteResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserMainQuestSeasonRoute",
"IUserMainQuestMainFlowStatus",
"IUserPortalCageStatus",
}),
}, nil
}
func (s *QuestServiceServer) SetQuestSceneChoice(ctx context.Context, req *pb.SetQuestSceneChoiceRequest) (*pb.SetQuestSceneChoiceResponse, error) {
log.Printf("[QuestService] SetQuestSceneChoice: questSceneId=%d choiceNumber=%d",
req.QuestSceneId, req.ChoiceNumber)
return &pb.SetQuestSceneChoiceResponse{
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *QuestServiceServer) ResetLimitContentQuestProgress(ctx context.Context, req *pb.ResetLimitContentQuestProgressRequest) (*pb.ResetLimitContentQuestProgressResponse, error) {
log.Printf("[QuestService] ResetLimitContentQuestProgress: eventQuestChapterId=%d questId=%d",
req.EventQuestChapterId, req.QuestId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
if _, exists := user.SideStoryQuests[req.QuestId]; exists {
user.SideStoryQuests[req.QuestId] = store.SideStoryQuestProgress{
HeadSideStoryQuestSceneId: 0,
SideStoryQuestStateType: model.SideStoryQuestStateUnknown,
LatestVersion: nowMillis,
}
}
delete(user.QuestLimitContentStatus, req.QuestId)
if user.SideStoryActiveProgress.CurrentSideStoryQuestId == req.QuestId {
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
LatestVersion: nowMillis,
}
}
})
return &pb.ResetLimitContentQuestProgressResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserSideStoryQuest",
"IUserSideStoryQuestSceneProgressStatus",
"IUserQuestLimitContentStatus",
}),
}, nil
}
func (s *QuestServiceServer) SetAutoSaleSetting(ctx context.Context, req *pb.SetAutoSaleSettingRequest) (*pb.SetAutoSaleSettingResponse, error) {
log.Printf("[QuestService] SetAutoSaleSetting: items=%d", len(req.AutoSaleSettingItem))
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.AutoSaleSettings = make(map[int32]store.AutoSaleSettingState, len(req.AutoSaleSettingItem))
for itemType, itemValue := range req.AutoSaleSettingItem {
user.AutoSaleSettings[itemType] = store.AutoSaleSettingState{
PossessionAutoSaleItemType: itemType,
PossessionAutoSaleItemValue: itemValue,
}
}
})
return &pb.SetAutoSaleSettingResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserAutoSaleSettingDetail",
}),
}, nil
}
@@ -0,0 +1,93 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type SideStoryQuestServiceServer struct {
pb.UnimplementedSideStoryQuestServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.SideStoryCatalog
}
func NewSideStoryQuestServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.SideStoryCatalog) *SideStoryQuestServiceServer {
return &SideStoryQuestServiceServer{users: users, sessions: sessions, catalog: catalog}
}
func buildSideStoryDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData {
tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames)
return userdata.BuildDiffFromTablesOrdered(tables, tableNames)
}
func (s *SideStoryQuestServiceServer) MoveSideStoryQuestProgress(ctx context.Context, req *pb.MoveSideStoryQuestRequest) (*pb.MoveSideStoryQuestResponse, error) {
log.Printf("[SideStoryQuestService] MoveSideStoryQuestProgress: sideStoryQuestId=%d", req.SideStoryQuestId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
firstSceneId := s.catalog.FirstSceneByQuestId[req.SideStoryQuestId]
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
existing, exists := user.SideStoryQuests[req.SideStoryQuestId]
var sceneId int32
if exists && existing.HeadSideStoryQuestSceneId > 0 {
sceneId = existing.HeadSideStoryQuestSceneId
} else {
sceneId = firstSceneId
}
user.SideStoryActiveProgress.CurrentSideStoryQuestId = req.SideStoryQuestId
user.SideStoryActiveProgress.CurrentSideStoryQuestSceneId = sceneId
user.SideStoryActiveProgress.LatestVersion = nowMillis
if !exists {
user.SideStoryQuests[req.SideStoryQuestId] = store.SideStoryQuestProgress{
HeadSideStoryQuestSceneId: firstSceneId,
SideStoryQuestStateType: model.SideStoryQuestStateActive,
LatestVersion: nowMillis,
}
}
})
return &pb.MoveSideStoryQuestResponse{
DiffUserData: buildSideStoryDiff(user, []string{
"IUserSideStoryQuest",
"IUserSideStoryQuestSceneProgressStatus",
}),
}, nil
}
func (s *SideStoryQuestServiceServer) UpdateSideStoryQuestSceneProgress(ctx context.Context, req *pb.UpdateSideStoryQuestSceneProgressRequest) (*pb.UpdateSideStoryQuestSceneProgressResponse, error) {
log.Printf("[SideStoryQuestService] UpdateSideStoryQuestSceneProgress: sideStoryQuestId=%d sceneId=%d",
req.SideStoryQuestId, req.SideStoryQuestSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.SideStoryActiveProgress.CurrentSideStoryQuestSceneId = req.SideStoryQuestSceneId
user.SideStoryActiveProgress.LatestVersion = nowMillis
progress := user.SideStoryQuests[req.SideStoryQuestId]
if req.SideStoryQuestSceneId > progress.HeadSideStoryQuestSceneId {
progress.HeadSideStoryQuestSceneId = req.SideStoryQuestSceneId
}
progress.LatestVersion = nowMillis
user.SideStoryQuests[req.SideStoryQuestId] = progress
})
return &pb.UpdateSideStoryQuestSceneProgressResponse{
DiffUserData: buildSideStoryDiff(user, []string{
"IUserSideStoryQuest",
"IUserSideStoryQuestSceneProgressStatus",
}),
}, nil
}
+144
View File
@@ -0,0 +1,144 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
type RewardServiceServer struct {
pb.UnimplementedRewardServiceServer
users store.UserRepository
sessions store.SessionRepository
bhCatalog *masterdata.BigHuntCatalog
granter *store.PossessionGranter
}
func NewRewardServiceServer(
users store.UserRepository,
sessions store.SessionRepository,
bhCatalog *masterdata.BigHuntCatalog,
granter *store.PossessionGranter,
) *RewardServiceServer {
return &RewardServiceServer{users: users, sessions: sessions, bhCatalog: bhCatalog, granter: granter}
}
func (s *RewardServiceServer) ReceiveBigHuntReward(ctx context.Context, _ *emptypb.Empty) (*pb.ReceiveBigHuntRewardResponse, error) {
log.Printf("[RewardService] ReceiveBigHuntReward")
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
weeklyVersion := gametime.WeeklyVersion(nowMillis)
var weeklyScoreResults []*pb.WeeklyScoreResult
var weeklyRewards []*pb.BigHuntReward
isReceived := false
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
ws := user.BigHuntWeeklyStatuses[weeklyVersion]
isReceived = ws.IsReceivedWeeklyReward
for _, boss := range s.bhCatalog.BossByBossId {
key := store.BigHuntWeeklyScoreKey{
BigHuntWeeklyVersion: weeklyVersion,
AttributeType: boss.AttributeType,
}
wms := user.BigHuntWeeklyMaxScores[key]
gradeIcon := s.bhCatalog.ResolveGradeIconId(boss.BigHuntBossId, wms.MaxScore)
weeklyScoreResults = append(weeklyScoreResults, &pb.WeeklyScoreResult{
AttributeType: boss.AttributeType,
BeforeMaxScore: wms.MaxScore,
CurrentMaxScore: wms.MaxScore,
BeforeAssetGradeIconId: gradeIcon,
CurrentAssetGradeIconId: gradeIcon,
AfterMaxScore: wms.MaxScore,
AfterAssetGradeIconId: gradeIcon,
})
}
if !isReceived {
for _, boss := range s.bhCatalog.BossByBossId {
rewardKey := masterdata.BigHuntWeeklyRewardKey{
ScheduleId: 1,
AttributeType: boss.AttributeType,
}
rewardGroupId := s.bhCatalog.ResolveActiveWeeklyRewardGroupId(rewardKey, nowMillis)
if rewardGroupId == 0 {
continue
}
weekKey := store.BigHuntWeeklyScoreKey{
BigHuntWeeklyVersion: weeklyVersion,
AttributeType: boss.AttributeType,
}
maxScore := user.BigHuntWeeklyMaxScores[weekKey].MaxScore
items := s.bhCatalog.CollectNewRewards(rewardGroupId, 0, maxScore)
for _, item := range items {
s.granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis)
weeklyRewards = append(weeklyRewards, &pb.BigHuntReward{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
Count: item.Count,
})
}
}
ws.IsReceivedWeeklyReward = true
ws.LatestVersion = nowMillis
user.BigHuntWeeklyStatuses[weeklyVersion] = ws
isReceived = true
}
})
if weeklyRewards == nil {
weeklyRewards = []*pb.BigHuntReward{}
}
if weeklyScoreResults == nil {
weeklyScoreResults = []*pb.WeeklyScoreResult{}
}
tables := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
"IUserBigHuntWeeklyStatus",
"IUserBigHuntWeeklyMaxScore",
"IUserConsumableItem",
"IUserMaterial",
})
return &pb.ReceiveBigHuntRewardResponse{
WeeklyScoreResult: weeklyScoreResults,
WeeklyScoreReward: weeklyRewards,
IsReceivedWeeklyScoreReward: isReceived,
LastWeekWeeklyScoreReward: []*pb.BigHuntReward{},
DiffUserData: userdata.BuildDiffFromTables(tables),
}, nil
}
func (s *RewardServiceServer) ReceivePvpReward(ctx context.Context, _ *emptypb.Empty) (*pb.ReceivePvpRewardResponse, error) {
log.Printf("[RewardService] ReceivePvpReward (stub)")
return &pb.ReceivePvpRewardResponse{
DiffUserData: map[string]*pb.DiffData{},
}, nil
}
func (s *RewardServiceServer) ReceiveLabyrinthSeasonReward(ctx context.Context, _ *emptypb.Empty) (*pb.ReceiveLabyrinthSeasonRewardResponse, error) {
log.Printf("[RewardService] ReceiveLabyrinthSeasonReward (stub)")
return &pb.ReceiveLabyrinthSeasonRewardResponse{
DiffUserData: map[string]*pb.DiffData{},
}, nil
}
func (s *RewardServiceServer) ReceiveMissionPassRemainingReward(ctx context.Context, _ *emptypb.Empty) (*pb.ReceiveMissionPassRemainingRewardResponse, error) {
log.Printf("[RewardService] ReceiveMissionPassRemainingReward (stub)")
return &pb.ReceiveMissionPassRemainingRewardResponse{
DiffUserData: map[string]*pb.DiffData{},
}, nil
}
+248
View File
@@ -0,0 +1,248 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
"google.golang.org/protobuf/types/known/emptypb"
)
var shopDiffTables = []string{
"IUserShopItem",
"IUserShopReplaceable",
"IUserShopReplaceableLineup",
"IUserGem",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
"IUserPremiumItem",
"IUserStatus",
"IUserCostume",
"IUserCostumeActiveSkill",
"IUserCharacter",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserWeaponNote",
"IUserWeaponStory",
}
type ShopServiceServer struct {
pb.UnimplementedShopServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.ShopCatalog
granter *store.PossessionGranter
}
func NewShopServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.ShopCatalog, granter *store.PossessionGranter) *ShopServiceServer {
return &ShopServiceServer{users: users, sessions: sessions, catalog: catalog, granter: granter}
}
func (s *ShopServiceServer) Buy(ctx context.Context, req *pb.BuyRequest) (*pb.BuyResponse, error) {
log.Printf("[ShopService] Buy: shopId=%d items=%v", req.ShopId, req.ShopItems)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
for shopItemId, qty := range req.ShopItems {
item, ok := s.catalog.Items[shopItemId]
if !ok {
log.Printf("[ShopService] Buy: unknown shopItemId=%d, skipping", shopItemId)
continue
}
totalPrice := item.Price * qty
if err := store.DeductPrice(user, item.PriceType, item.PriceId, totalPrice); err != nil {
log.Printf("[ShopService] Buy: deduct failed shopItemId=%d: %v", shopItemId, err)
continue
}
for _, content := range s.catalog.Contents[shopItemId] {
s.granter.GrantFull(user,
model.PossessionType(content.PossessionType),
content.PossessionId,
content.Count*qty,
nowMillis,
)
}
s.applyContentEffects(user, shopItemId, qty, nowMillis)
si := user.ShopItems[shopItemId]
si.ShopItemId = shopItemId
si.BoughtCount += qty
si.LatestBoughtCountChangedDatetime = nowMillis
si.LatestVersion = nowMillis
user.ShopItems[shopItemId] = si
}
})
if err != nil {
return nil, fmt.Errorf("shop buy: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables))
return &pb.BuyResponse{
OverflowPossession: []*pb.Possession{},
DiffUserData: diff,
}, nil
}
func (s *ShopServiceServer) RefreshUserData(ctx context.Context, req *pb.RefreshRequest) (*pb.RefreshResponse, error) {
log.Printf("[ShopService] RefreshUserData: isGemUsed=%v", req.IsGemUsed)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
if len(user.ShopReplaceableLineup) == 0 && len(s.catalog.ItemShopPool) > 0 {
for i, itemId := range s.catalog.ItemShopPool {
slot := int32(i + 1)
user.ShopReplaceableLineup[slot] = store.UserShopReplaceableLineupState{
SlotNumber: slot,
ShopItemId: itemId,
LatestVersion: nowMillis,
}
}
}
if req.IsGemUsed {
user.ShopReplaceable.LineupUpdateCount++
user.ShopReplaceable.LatestLineupUpdateDatetime = nowMillis
for _, itemId := range s.catalog.ItemShopPool {
if si, ok := user.ShopItems[itemId]; ok {
si.BoughtCount = 0
si.LatestVersion = nowMillis
user.ShopItems[itemId] = si
}
}
}
})
if err != nil {
return nil, fmt.Errorf("shop refresh: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables))
return &pb.RefreshResponse{
DiffUserData: diff,
}, nil
}
func (s *ShopServiceServer) GetCesaLimit(_ context.Context, _ *emptypb.Empty) (*pb.GetCesaLimitResponse, error) {
log.Printf("[ShopService] GetCesaLimit")
return &pb.GetCesaLimitResponse{
CesaLimit: []*pb.CesaLimit{},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *ShopServiceServer) CreatePurchaseTransaction(ctx context.Context, req *pb.CreatePurchaseTransactionRequest) (*pb.CreatePurchaseTransactionResponse, error) {
log.Printf("[ShopService] CreatePurchaseTransaction: shopId=%d shopItemId=%d productId=%s",
req.ShopId, req.ShopItemId, req.ProductId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
item, ok := s.catalog.Items[req.ShopItemId]
if !ok {
log.Printf("[ShopService] CreatePurchaseTransaction: unknown shopItemId=%d", req.ShopItemId)
return
}
if err := store.DeductPrice(user, item.PriceType, item.PriceId, item.Price); err != nil {
log.Printf("[ShopService] CreatePurchaseTransaction: deduct failed: %v", err)
}
for _, content := range s.catalog.Contents[req.ShopItemId] {
s.granter.GrantFull(user,
model.PossessionType(content.PossessionType),
content.PossessionId,
content.Count,
nowMillis,
)
}
s.applyContentEffects(user, req.ShopItemId, 1, nowMillis)
si := user.ShopItems[req.ShopItemId]
si.ShopItemId = req.ShopItemId
si.BoughtCount++
if item.ShopItemLimitedStockId > 0 {
if maxCount, ok := s.catalog.LimitedStock[item.ShopItemLimitedStockId]; ok && si.BoughtCount >= maxCount {
si.BoughtCount = 0
}
}
si.LatestBoughtCountChangedDatetime = nowMillis
si.LatestVersion = nowMillis
user.ShopItems[req.ShopItemId] = si
})
if err != nil {
return nil, fmt.Errorf("create purchase transaction: %w", err)
}
txId := fmt.Sprintf("tx_%d_%d_%d", userId, req.ShopItemId, nowMillis)
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables))
return &pb.CreatePurchaseTransactionResponse{
PurchaseTransactionId: txId,
DiffUserData: diff,
}, nil
}
func (s *ShopServiceServer) PurchaseGooglePlayStoreProduct(ctx context.Context, req *pb.PurchaseGooglePlayStoreProductRequest) (*pb.PurchaseGooglePlayStoreProductResponse, error) {
log.Printf("[ShopService] PurchaseGooglePlayStoreProduct: txId=%s", req.PurchaseTransactionId)
userId := currentUserId(ctx, s.users, s.sessions)
snapshot, err := s.users.SnapshotUser(userId)
if err != nil {
return nil, fmt.Errorf("purchase google play: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables))
return &pb.PurchaseGooglePlayStoreProductResponse{
OverflowPossession: []*pb.Possession{},
DiffUserData: diff,
}, nil
}
func (s *ShopServiceServer) applyContentEffects(user *store.UserState, shopItemId, qty int32, nowMillis int64) {
for _, effect := range s.catalog.Effects[shopItemId] {
switch effect.EffectTargetType {
case model.EffectTargetStaminaRecovery:
maxMillis := s.catalog.MaxStaminaMillis[user.Status.Level]
millis := s.resolveEffectMillis(effect.EffectValueType, effect.EffectValue, user.Status.Level)
store.RecoverStamina(user, millis*qty, maxMillis, nowMillis)
default:
log.Printf("[ShopService] unhandled effect: shopItemId=%d targetType=%d", shopItemId, effect.EffectTargetType)
}
}
}
func (s *ShopServiceServer) resolveEffectMillis(effectValueType, effectValue, userLevel int32) int32 {
switch effectValueType {
case model.EffectValueFixed:
return effectValue
case model.EffectValuePermil:
maxMillis := s.catalog.MaxStaminaMillis[userLevel]
return effectValue * maxMillis / 1000
default:
return 0
}
}
+59
View File
@@ -0,0 +1,59 @@
package service
import (
"context"
"lunar-tear/server/internal/store"
"google.golang.org/grpc/metadata"
)
var startedGameStartTables = []string{
"IUserProfile",
"IUserCharacter",
"IUserCostume",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserCompanion",
"IUserDeckCharacter",
"IUserDeck",
"IUserGem",
"IUserMission",
"IUserMainQuestFlowStatus",
"IUserMainQuestMainFlowStatus",
"IUserMainQuestProgressStatus",
"IUserMainQuestSeasonRoute",
"IUserQuest",
"IUserQuestMission",
"IUserTutorialProgress",
"IUserWeaponNote",
"IUserWeaponStory",
"IUserCostumeActiveSkill",
"IUserDeckTypeNote",
"IUserDeckSubWeaponGroup",
"IUserDeckPartsGroup",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
}
var gimmickDiffTables = []string{
"IUserGimmick",
"IUserGimmickOrnamentProgress",
"IUserGimmickSequence",
"IUserGimmickUnlock",
}
func currentUserId(ctx context.Context, users store.UserRepository, sessions store.SessionRepository) int64 {
if md, ok := metadata.FromIncomingContext(ctx); ok {
if vals := md.Get("x-session-key"); len(vals) > 0 {
if userId, err := sessions.ResolveUserId(vals[0]); err == nil {
return userId
}
}
}
defaultId, _ := users.DefaultUserId()
return defaultId
}
+93
View File
@@ -0,0 +1,93 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type TutorialServiceServer struct {
pb.UnimplementedTutorialServiceServer
users store.UserRepository
sessions store.SessionRepository
engine *questflow.QuestHandler
}
func NewTutorialServiceServer(users store.UserRepository, sessions store.SessionRepository, engine *questflow.QuestHandler) *TutorialServiceServer {
return &TutorialServiceServer{users: users, sessions: sessions, engine: engine}
}
func (s *TutorialServiceServer) SetTutorialProgress(ctx context.Context, req *pb.SetTutorialProgressRequest) (*pb.SetTutorialProgressResponse, error) {
log.Printf("[TutorialService] SetTutorialProgress: type=%d phase=%d choice=%d", req.TutorialType, req.ProgressPhase, req.ChoiceId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
var grants []questflow.RewardGrant
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.Tutorials[req.TutorialType] = store.TutorialProgressState{
TutorialType: req.TutorialType,
ProgressPhase: req.ProgressPhase,
ChoiceId: req.ChoiceId,
}
if req.TutorialType == int32(model.TutorialTypeMenuFirst) ||
req.TutorialType == int32(model.TutorialTypeMenuSecond) {
store.EnsureDefaultDeck(user, nowMillis)
}
grants = s.engine.ApplyTutorialReward(user, model.TutorialType(req.TutorialType), req.ChoiceId, nowMillis)
})
tables := []string{"IUserTutorialProgress"}
if req.TutorialType == int32(model.TutorialTypeMenuFirst) ||
req.TutorialType == int32(model.TutorialTypeMenuSecond) {
tables = append(tables,
"IUserCharacter", "IUserCostume", "IUserWeapon",
"IUserWeaponSkill", "IUserWeaponAbility",
"IUserCompanion", "IUserDeckCharacter", "IUserDeck",
)
}
if len(grants) > 0 {
tables = append(tables, "IUserCompanion")
}
result := userdata.SelectTables(userdata.FullClientTableMap(user), tables)
for _, t := range tables {
log.Printf("[TutorialService] DiffTable %s -> %s", t, result[t])
}
rewards := make([]*pb.TutorialChoiceReward, len(grants))
for i, g := range grants {
rewards[i] = &pb.TutorialChoiceReward{
PossessionType: int32(g.PossessionType),
PossessionId: g.PossessionId,
Count: g.Count,
}
}
return &pb.SetTutorialProgressResponse{
TutorialChoiceReward: rewards,
DiffUserData: userdata.BuildDiffFromTables(result),
}, nil
}
func (s *TutorialServiceServer) SetTutorialProgressAndReplaceDeck(ctx context.Context, req *pb.SetTutorialProgressAndReplaceDeckRequest) (*pb.SetTutorialProgressAndReplaceDeckResponse, error) {
log.Printf("[TutorialService] SetTutorialProgressAndReplaceDeck: type=%d phase=%d deckType=%d deckNumber=%d", req.TutorialType, req.ProgressPhase, req.DeckType, req.UserDeckNumber)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.Tutorials[req.TutorialType] = store.TutorialProgressState{
TutorialType: req.TutorialType,
ProgressPhase: req.ProgressPhase,
}
if req.Deck != nil {
store.ApplyDeckReplacement(user, model.DeckType(req.DeckType), req.UserDeckNumber, deckSlotsFromProto(req.Deck), gametime.NowMillis())
}
})
return &pb.SetTutorialProgressAndReplaceDeckResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{
"IUserTutorialProgress",
"IUserDeck",
"IUserDeckCharacter",
"IUserDeckSubWeaponGroup",
})),
}, nil
}
+268
View File
@@ -0,0 +1,268 @@
package service
import (
"context"
"fmt"
"log"
"sort"
"time"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
type UserServiceServer struct {
pb.UnimplementedUserServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewUserServiceServer(users store.UserRepository, sessions store.SessionRepository) *UserServiceServer {
return &UserServiceServer{users: users, sessions: sessions}
}
func setCommonResponseTrailers(ctx context.Context, diff map[string]*pb.DiffData, includeUpdateNames bool) {
keys := make([]string, 0, len(diff))
for key := range diff {
keys = append(keys, key)
}
sort.Strings(keys)
var pairs []string
if includeUpdateNames && len(keys) > 0 {
pairs = append(pairs, "x-apb-update-user-data-names", keys[0])
for _, key := range keys[1:] {
pairs[len(pairs)-1] += "," + key
}
}
if err := grpc.SetTrailer(ctx, metadata.Pairs(pairs...)); err != nil {
log.Printf("[UserService] failed to set trailers: %v", err)
}
}
func (s *UserServiceServer) RegisterUser(ctx context.Context, req *pb.RegisterUserRequest) (*pb.RegisterUserResponse, error) {
user, err := s.users.EnsureUser(req.Uuid)
if err != nil {
return nil, fmt.Errorf("ensure user: %w", err)
}
log.Printf("[UserService] RegisterUser: uuid=%s terminalId=%s -> userId=%d", req.Uuid, req.TerminalId, user.UserId)
return &pb.RegisterUserResponse{
UserId: user.UserId,
Signature: fmt.Sprintf("sig_%d_%d", user.UserId, gametime.Now().Unix()),
DiffUserData: userdata.BuildDiffFromTables(userdata.FirstEntranceClientTableMap(user)),
}, nil
}
func (s *UserServiceServer) Auth(ctx context.Context, req *pb.AuthUserRequest) (*pb.AuthUserResponse, error) {
log.Printf("[UserService] Auth: uuid=%s", req.Uuid)
user, session, err := s.sessions.CreateSession(req.Uuid, 24*time.Hour)
if err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
return &pb.AuthUserResponse{
SessionKey: session.SessionKey,
ExpireDatetime: timestamppb.New(session.ExpireAt),
Signature: req.Signature,
UserId: user.UserId,
DiffUserData: userdata.BuildDiffFromTables(userdata.FirstEntranceClientTableMap(user)),
}, nil
}
func (s *UserServiceServer) GameStart(ctx context.Context, _ *emptypb.Empty) (*pb.GameStartResponse, error) {
log.Printf("[UserService] GameStart")
if md, ok := metadata.FromIncomingContext(ctx); ok {
if vals := md.Get("x-session-key"); len(vals) > 0 {
log.Printf("[UserService] GameStart session: %s", vals[0])
}
}
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.GameStartDatetime = gametime.NowMillis()
})
fullTables := userdata.FullClientTableMap(user)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(fullTables, startedGameStartTables))
setCommonResponseTrailers(ctx, diff, true)
return &pb.GameStartResponse{
// Apply only the starter outgame rows we need after title completion.
// Keep IUser and other risky core-account rows out of GameStart diff.
DiffUserData: diff,
}, nil
}
func (s *UserServiceServer) TransferUser(ctx context.Context, req *pb.TransferUserRequest) (*pb.TransferUserResponse, error) {
log.Printf("[UserService] TransferUser")
user, err := s.users.EnsureUser(req.Uuid)
if err != nil {
return nil, fmt.Errorf("ensure user: %w", err)
}
return &pb.TransferUserResponse{
UserId: user.UserId,
Signature: "transferred-sig",
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *UserServiceServer) SetUserName(ctx context.Context, req *pb.SetUserNameRequest) (*pb.SetUserNameResponse, error) {
log.Printf("[UserService] SetUserName: %s", req.Name)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
nowMillis := gametime.NowMillis()
user.Profile.Name = req.Name
user.Profile.NameUpdateDatetime = nowMillis
})
return &pb.SetUserNameResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})),
}, nil
}
func (s *UserServiceServer) SetUserMessage(ctx context.Context, req *pb.SetUserMessageRequest) (*pb.SetUserMessageResponse, error) {
log.Printf("[UserService] SetUserMessage: %s", req.Message)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
nowMillis := gametime.NowMillis()
user.Profile.Message = req.Message
user.Profile.MessageUpdateDatetime = nowMillis
})
return &pb.SetUserMessageResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})),
}, nil
}
func (s *UserServiceServer) SetUserFavoriteCostumeId(ctx context.Context, req *pb.SetUserFavoriteCostumeIdRequest) (*pb.SetUserFavoriteCostumeIdResponse, error) {
log.Printf("[UserService] SetUserFavoriteCostumeId: %d", req.FavoriteCostumeId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
nowMillis := gametime.NowMillis()
user.Profile.FavoriteCostumeId = req.FavoriteCostumeId
user.Profile.FavoriteCostumeIdUpdateDatetime = nowMillis
})
return &pb.SetUserFavoriteCostumeIdResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})),
}, nil
}
func (s *UserServiceServer) GetUserProfile(ctx context.Context, req *pb.GetUserProfileRequest) (*pb.GetUserProfileResponse, error) {
log.Printf("[UserService] GetUserProfile: playerId=%d", req.PlayerId)
userId := req.PlayerId
if userId == 0 {
userId = currentUserId(ctx, s.users, s.sessions)
}
user, err := s.users.SnapshotUser(userId)
if err != nil {
return &pb.GetUserProfileResponse{DiffUserData: userdata.EmptyDiff()}, nil
}
deckCharacters := []*pb.ProfileDeckCharacter{}
if deck, ok := user.Decks[store.DeckKey{DeckType: model.DeckTypeQuest, UserDeckNumber: 1}]; ok && deck.UserDeckCharacterUuid01 != "" {
if deckCharacter, ok := user.DeckCharacters[deck.UserDeckCharacterUuid01]; ok {
costumeId := int32(0)
if costume, ok := user.Costumes[deckCharacter.UserCostumeUuid]; ok {
costumeId = costume.CostumeId
}
mainWeaponId := int32(0)
mainWeaponLevel := int32(0)
if weapon, ok := user.Weapons[deckCharacter.MainUserWeaponUuid]; ok {
mainWeaponId = weapon.WeaponId
mainWeaponLevel = weapon.Level
}
deckCharacters = append(deckCharacters, &pb.ProfileDeckCharacter{
CostumeId: costumeId,
MainWeaponId: mainWeaponId,
MainWeaponLevel: mainWeaponLevel,
})
}
}
return &pb.GetUserProfileResponse{
Level: user.Status.Level,
Name: user.Profile.Name,
FavoriteCostumeId: user.Profile.FavoriteCostumeId,
Message: user.Profile.Message,
IsFriend: false,
LatestUsedDeck: &pb.ProfileDeck{
Power: 100,
DeckCharacter: deckCharacters,
},
PvpInfo: &pb.ProfilePvpInfo{},
GamePlayHistory: &pb.GamePlayHistory{
HistoryItem: []*pb.PlayHistoryItem{},
HistoryCategoryGraphItem: []*pb.PlayHistoryCategoryGraphItem{},
},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *UserServiceServer) SetBirthYearMonth(ctx context.Context, req *pb.SetBirthYearMonthRequest) (*pb.SetBirthYearMonthResponse, error) {
log.Printf("[UserService] SetBirthYearMonth: %d/%d", req.BirthYear, req.BirthMonth)
userId := currentUserId(ctx, s.users, s.sessions)
_, _ = s.users.UpdateUser(userId, func(user *store.UserState) {
user.BirthYear = req.BirthYear
user.BirthMonth = req.BirthMonth
})
return &pb.SetBirthYearMonthResponse{DiffUserData: userdata.EmptyDiff()}, nil
}
func (s *UserServiceServer) GetBirthYearMonth(ctx context.Context, _ *emptypb.Empty) (*pb.GetBirthYearMonthResponse, error) {
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return &pb.GetBirthYearMonthResponse{BirthYear: 2000, BirthMonth: 1, DiffUserData: userdata.EmptyDiff()}, nil
}
return &pb.GetBirthYearMonthResponse{BirthYear: user.BirthYear, BirthMonth: user.BirthMonth, DiffUserData: userdata.EmptyDiff()}, nil
}
func (s *UserServiceServer) GetChargeMoney(ctx context.Context, _ *emptypb.Empty) (*pb.GetChargeMoneyResponse, error) {
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return &pb.GetChargeMoneyResponse{ChargeMoneyThisMonth: 0, DiffUserData: userdata.EmptyDiff()}, nil
}
return &pb.GetChargeMoneyResponse{ChargeMoneyThisMonth: user.ChargeMoneyThisMonth, DiffUserData: userdata.EmptyDiff()}, nil
}
func (s *UserServiceServer) SetUserSetting(ctx context.Context, req *pb.SetUserSettingRequest) (*pb.SetUserSettingResponse, error) {
log.Printf("[UserService] SetUserSetting: isNotifyPurchaseAlert=%v", req.IsNotifyPurchaseAlert)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.Setting.IsNotifyPurchaseAlert = req.IsNotifyPurchaseAlert
})
return &pb.SetUserSettingResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserSetting"})),
}, nil
}
func (s *UserServiceServer) GetAndroidArgs(ctx context.Context, req *pb.GetAndroidArgsRequest) (*pb.GetAndroidArgsResponse, error) {
return &pb.GetAndroidArgsResponse{Nonce: "Mama", ApiKey: "1234567890", DiffUserData: userdata.EmptyDiff()}, nil
}
func (s *UserServiceServer) GetBackupToken(ctx context.Context, req *pb.GetBackupTokenRequest) (*pb.GetBackupTokenResponse, error) {
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return &pb.GetBackupTokenResponse{BackupToken: "mock-backup-token", DiffUserData: userdata.EmptyDiff()}, nil
}
return &pb.GetBackupTokenResponse{BackupToken: user.BackupToken, DiffUserData: userdata.EmptyDiff()}, nil
}
func (s *UserServiceServer) CheckTransferSetting(ctx context.Context, _ *emptypb.Empty) (*pb.CheckTransferSettingResponse, error) {
return &pb.CheckTransferSettingResponse{DiffUserData: userdata.EmptyDiff()}, nil
}
func (s *UserServiceServer) GetUserGamePlayNote(ctx context.Context, req *pb.GetUserGamePlayNoteRequest) (*pb.GetUserGamePlayNoteResponse, error) {
return &pb.GetUserGamePlayNoteResponse{DiffUserData: userdata.EmptyDiff()}, nil
}
+760
View File
@@ -0,0 +1,760 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/gameutil"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
var weaponDiffTables = []string{
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserMaterial",
"IUserConsumableItem",
"IUserWeaponStory",
}
var limitBreakDiffTables = []string{
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserMaterial",
"IUserConsumableItem",
"IUserWeaponNote",
}
type WeaponServiceServer struct {
pb.UnimplementedWeaponServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.WeaponCatalog
config *masterdata.GameConfig
}
func NewWeaponServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.WeaponCatalog, config *masterdata.GameConfig) *WeaponServiceServer {
return &WeaponServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
}
func (s *WeaponServiceServer) Protect(ctx context.Context, req *pb.ProtectRequest) (*pb.ProtectResponse, error) {
log.Printf("[WeaponService] Protect: uuids=%v", req.UserWeaponUuid)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, uuid := range req.UserWeaponUuid {
weapon, ok := user.Weapons[uuid]
if !ok {
log.Printf("[WeaponService] Protect: weapon uuid=%s not found", uuid)
continue
}
weapon.IsProtected = true
weapon.LatestVersion = nowMillis
user.Weapons[uuid] = weapon
}
})
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserWeapon"}))
return &pb.ProtectResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) Unprotect(ctx context.Context, req *pb.UnprotectRequest) (*pb.UnprotectResponse, error) {
log.Printf("[WeaponService] Unprotect: uuids=%v", req.UserWeaponUuid)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, uuid := range req.UserWeaponUuid {
weapon, ok := user.Weapons[uuid]
if !ok {
log.Printf("[WeaponService] Unprotect: weapon uuid=%s not found", uuid)
continue
}
weapon.IsProtected = false
weapon.LatestVersion = nowMillis
user.Weapons[uuid] = weapon
}
})
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserWeapon"}))
return &pb.UnprotectResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.EnhanceByMaterialRequest) (*pb.EnhanceByMaterialResponse, error) {
log.Printf("[WeaponService] EnhanceByMaterial: uuid=%s materials=%v", req.UserWeaponUuid, req.Materials)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
weapon, ok := user.Weapons[req.UserWeaponUuid]
if !ok {
log.Printf("[WeaponService] EnhanceByMaterial: weapon uuid=%s not found", req.UserWeaponUuid)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceByMaterial: weapon master id=%d not found", weapon.WeaponId)
return
}
totalExp := int32(0)
totalMaterialCount := int32(0)
for materialId, count := range req.Materials {
mat, ok := s.catalog.Materials[materialId]
if !ok {
log.Printf("[WeaponService] EnhanceByMaterial: material id=%d not found, skipping", materialId)
continue
}
cur := user.Materials[materialId]
if cur < count {
log.Printf("[WeaponService] EnhanceByMaterial: insufficient material id=%d have=%d need=%d", materialId, cur, count)
continue
}
user.Materials[materialId] = cur - count
totalMaterialCount += count
expPerUnit := mat.EffectValue
if mat.WeaponType != 0 && mat.WeaponType == wm.WeaponType {
expPerUnit = expPerUnit * s.config.MaterialSameWeaponExpCoefficientPermil / 1000
}
totalExp += expPerUnit * count
}
if costFunc, ok := s.catalog.GoldCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] EnhanceByMaterial: gold cost=%d (materials=%d)", goldCost, totalMaterialCount)
}
weapon.Exp += totalExp
if thresholds, ok := s.catalog.ExpByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds)
}
weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon
log.Printf("[WeaponService] EnhanceByMaterial: weaponId=%d +%d exp -> total=%d level=%d", weapon.WeaponId, totalExp, weapon.Exp, weapon.Level)
})
if err != nil {
return nil, fmt.Errorf("weapon enhance by material: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables))
return &pb.EnhanceByMaterialResponse{
IsGreatSuccess: false,
SurplusEnhanceMaterial: map[int32]int32{},
DiffUserData: diff,
}, nil
}
func (s *WeaponServiceServer) Sell(ctx context.Context, req *pb.SellRequest) (*pb.SellResponse, error) {
log.Printf("[WeaponService] Sell: uuids=%v", req.UserWeaponUuid)
userId := currentUserId(ctx, s.users, s.sessions)
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}).
Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}).
Track("IUserWeaponAbility", oldUser, userdata.SortedWeaponAbilityRecords, []string{"userId", "userWeaponUuid", "slotNumber"})
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
totalGold := int32(0)
for _, uuid := range req.UserWeaponUuid {
weapon, ok := user.Weapons[uuid]
if !ok {
log.Printf("[WeaponService] Sell: weapon uuid=%s not found, skipping", uuid)
continue
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] Sell: weapon master id=%d not found, skipping", weapon.WeaponId)
continue
}
if sellFunc, ok := s.catalog.SellPriceByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
totalGold += sellFunc.Evaluate(weapon.Level)
}
if medals, ok := s.catalog.MedalsByWeaponId[weapon.WeaponId]; ok {
for itemId, count := range medals {
user.ConsumableItems[itemId] += count
}
}
delete(user.Weapons, uuid)
delete(user.WeaponSkills, uuid)
delete(user.WeaponAbilities, uuid)
}
if totalGold > 0 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold
log.Printf("[WeaponService] Sell: granted %d gold", totalGold)
}
})
if err != nil {
return nil, fmt.Errorf("weapon sell: %w", err)
}
sellDiffTables := []string{"IUserWeapon", "IUserWeaponSkill", "IUserWeaponAbility", "IUserConsumableItem"}
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), sellDiffTables)
diff := tracker.Apply(snapshot, tables)
return &pb.SellResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) Evolve(ctx context.Context, req *pb.EvolveRequest) (*pb.EvolveResponse, error) {
log.Printf("[WeaponService] Evolve: uuid=%s", req.UserWeaponUuid)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
weapon, ok := user.Weapons[req.UserWeaponUuid]
if !ok {
log.Printf("[WeaponService] Evolve: weapon uuid=%s not found", req.UserWeaponUuid)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] Evolve: weapon master id=%d not found", weapon.WeaponId)
return
}
evolvedId, ok := s.catalog.EvolutionNextWeaponId[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] Evolve: no evolution for weaponId=%d", weapon.WeaponId)
return
}
totalMaterialCount := int32(0)
mats := s.catalog.EvolutionMaterials[wm.WeaponEvolutionMaterialGroupId]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
if cur < cost {
log.Printf("[WeaponService] Evolve: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost)
cost = cur
}
user.Materials[mat.MaterialId] = cur - cost
totalMaterialCount += cost
}
if costFunc, ok := s.catalog.EvolutionCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] Evolve: gold cost=%d", goldCost)
}
weapon.WeaponId = evolvedId
weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon
evolvedMaster, ok := s.catalog.Weapons[evolvedId]
if ok {
if slots, ok := s.catalog.AbilitySlots[evolvedMaster.WeaponAbilityGroupId]; ok {
abilities := make([]store.WeaponAbilityState, len(slots))
for i, slot := range slots {
abilities[i] = store.WeaponAbilityState{
UserWeaponUuid: req.UserWeaponUuid,
SlotNumber: slot,
Level: 1,
}
}
user.WeaponAbilities[req.UserWeaponUuid] = abilities
}
}
log.Printf("[WeaponService] Evolve: weaponId %d -> %d", wm.WeaponId, evolvedId)
s.checkEvolutionStoryUnlocks(user, evolvedId, nowMillis)
})
if err != nil {
return nil, fmt.Errorf("weapon evolve: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables))
return &pb.EvolveResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceSkillRequest) (*pb.EnhanceSkillResponse, error) {
log.Printf("[WeaponService] EnhanceSkill: uuid=%s skillId=%d addLevel=%d", req.UserWeaponUuid, req.SkillId, req.AddLevelCount)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
weapon, ok := user.Weapons[req.UserWeaponUuid]
if !ok {
log.Printf("[WeaponService] EnhanceSkill: weapon uuid=%s not found", req.UserWeaponUuid)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceSkill: weapon master id=%d not found", weapon.WeaponId)
return
}
groupRows := s.catalog.SkillGroupsByGroupId[wm.WeaponSkillGroupId]
var skillGroup *masterdata.WeaponSkillGroupRow
for i := range groupRows {
if groupRows[i].SkillId == req.SkillId {
skillGroup = &groupRows[i]
break
}
}
if skillGroup == nil {
log.Printf("[WeaponService] EnhanceSkill: skillId=%d not found in group=%d", req.SkillId, wm.WeaponSkillGroupId)
return
}
skills := user.WeaponSkills[req.UserWeaponUuid]
skillIdx := -1
for i, sk := range skills {
if sk.SlotNumber == skillGroup.SlotNumber {
skillIdx = i
break
}
}
if skillIdx < 0 {
log.Printf("[WeaponService] EnhanceSkill: slot=%d not found for weapon uuid=%s", skillGroup.SlotNumber, req.UserWeaponUuid)
return
}
maxLevelFunc, ok := s.catalog.SkillMaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]
if !ok {
log.Printf("[WeaponService] EnhanceSkill: no max skill level func for enhanceId=%d", wm.WeaponSpecificEnhanceId)
return
}
maxLevel := maxLevelFunc.Evaluate(weapon.LimitBreakCount)
currentLevel := skills[skillIdx].Level
addCount := req.AddLevelCount
if currentLevel+addCount > maxLevel {
addCount = maxLevel - currentLevel
}
if addCount <= 0 {
log.Printf("[WeaponService] EnhanceSkill: already at max level %d", currentLevel)
return
}
enhanceMatId := skillGroup.WeaponSkillEnhancementMaterialId
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
key := [2]int32{enhanceMatId, lvl}
mats := s.catalog.SkillEnhanceMats[key]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
if cur < cost {
log.Printf("[WeaponService] EnhanceSkill: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost)
cost = cur
}
user.Materials[mat.MaterialId] = cur - cost
}
if costFunc, ok := s.catalog.SkillCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
goldCost := costFunc.Evaluate(lvl + 1)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
}
}
skills[skillIdx].Level = currentLevel + addCount
user.WeaponSkills[req.UserWeaponUuid] = skills
log.Printf("[WeaponService] EnhanceSkill: skillId=%d level %d -> %d", req.SkillId, currentLevel, skills[skillIdx].Level)
weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon
})
if err != nil {
return nil, fmt.Errorf("weapon enhance skill: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables))
return &pb.EnhanceSkillResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.EnhanceAbilityRequest) (*pb.EnhanceAbilityResponse, error) {
log.Printf("[WeaponService] EnhanceAbility: uuid=%s abilityId=%d addLevel=%d", req.UserWeaponUuid, req.AbilityId, req.AddLevelCount)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
weapon, ok := user.Weapons[req.UserWeaponUuid]
if !ok {
log.Printf("[WeaponService] EnhanceAbility: weapon uuid=%s not found", req.UserWeaponUuid)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceAbility: weapon master id=%d not found", weapon.WeaponId)
return
}
groupRows := s.catalog.AbilityGroupsByGroupId[wm.WeaponAbilityGroupId]
var abilityGroup *masterdata.WeaponAbilityGroupRow
for i := range groupRows {
if groupRows[i].AbilityId == req.AbilityId {
abilityGroup = &groupRows[i]
break
}
}
if abilityGroup == nil {
log.Printf("[WeaponService] EnhanceAbility: abilityId=%d not found in group=%d", req.AbilityId, wm.WeaponAbilityGroupId)
return
}
abilities := user.WeaponAbilities[req.UserWeaponUuid]
abilityIdx := -1
for i, ab := range abilities {
if ab.SlotNumber == abilityGroup.SlotNumber {
abilityIdx = i
break
}
}
if abilityIdx < 0 {
log.Printf("[WeaponService] EnhanceAbility: slot=%d not found for weapon uuid=%s", abilityGroup.SlotNumber, req.UserWeaponUuid)
return
}
maxLevelFunc, ok := s.catalog.AbilityMaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]
if !ok {
log.Printf("[WeaponService] EnhanceAbility: no max ability level func for enhanceId=%d", wm.WeaponSpecificEnhanceId)
return
}
maxLevel := maxLevelFunc.Evaluate(weapon.LimitBreakCount)
currentLevel := abilities[abilityIdx].Level
addCount := req.AddLevelCount
if currentLevel+addCount > maxLevel {
addCount = maxLevel - currentLevel
}
if addCount <= 0 {
log.Printf("[WeaponService] EnhanceAbility: already at max level %d", currentLevel)
return
}
enhanceMatId := abilityGroup.WeaponAbilityEnhancementMaterialId
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
key := [2]int32{enhanceMatId, lvl}
mats := s.catalog.AbilityEnhanceMats[key]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
if cur < cost {
log.Printf("[WeaponService] EnhanceAbility: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost)
cost = cur
}
user.Materials[mat.MaterialId] = cur - cost
}
if costFunc, ok := s.catalog.AbilityCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
goldCost := costFunc.Evaluate(lvl + 1)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
}
}
abilities[abilityIdx].Level = currentLevel + addCount
user.WeaponAbilities[req.UserWeaponUuid] = abilities
log.Printf("[WeaponService] EnhanceAbility: abilityId=%d level %d -> %d", req.AbilityId, currentLevel, abilities[abilityIdx].Level)
weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon
})
if err != nil {
return nil, fmt.Errorf("weapon enhance ability: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables))
return &pb.EnhanceAbilityResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) LimitBreakByMaterial(ctx context.Context, req *pb.LimitBreakByMaterialRequest) (*pb.LimitBreakByMaterialResponse, error) {
log.Printf("[WeaponService] LimitBreakByMaterial: uuid=%s materials=%v", req.UserWeaponUuid, req.Materials)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
weapon, ok := user.Weapons[req.UserWeaponUuid]
if !ok {
log.Printf("[WeaponService] LimitBreakByMaterial: weapon uuid=%s not found", req.UserWeaponUuid)
return
}
if weapon.LimitBreakCount >= s.config.WeaponLimitBreakAvailableCount {
log.Printf("[WeaponService] LimitBreakByMaterial: already at max limit break %d", weapon.LimitBreakCount)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] LimitBreakByMaterial: weapon master id=%d not found", weapon.WeaponId)
return
}
remaining := s.config.WeaponLimitBreakAvailableCount - weapon.LimitBreakCount
totalMaterialCount := int32(0)
for materialId, count := range req.Materials {
if totalMaterialCount >= remaining {
break
}
if count > remaining-totalMaterialCount {
count = remaining - totalMaterialCount
}
cur := user.Materials[materialId]
if cur < count {
log.Printf("[WeaponService] LimitBreakByMaterial: insufficient material id=%d have=%d need=%d", materialId, cur, count)
count = cur
}
user.Materials[materialId] = cur - count
totalMaterialCount += count
}
if costFunc, ok := s.catalog.LimitBreakCostByMaterialByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] LimitBreakByMaterial: gold cost=%d", goldCost)
}
weapon.LimitBreakCount += totalMaterialCount
weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon
note := user.WeaponNotes[weapon.WeaponId]
if note.MaxLimitBreakCount < weapon.LimitBreakCount {
note.MaxLimitBreakCount = weapon.LimitBreakCount
note.LatestVersion = nowMillis
user.WeaponNotes[weapon.WeaponId] = note
}
log.Printf("[WeaponService] LimitBreakByMaterial: weaponId=%d limitBreak -> %d", weapon.WeaponId, weapon.LimitBreakCount)
})
if err != nil {
return nil, fmt.Errorf("weapon limit break by material: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, limitBreakDiffTables))
return &pb.LimitBreakByMaterialResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.LimitBreakByWeaponRequest) (*pb.LimitBreakByWeaponResponse, error) {
log.Printf("[WeaponService] LimitBreakByWeapon: uuid=%s materialUuids=%v", req.UserWeaponUuid, req.MaterialUserWeaponUuids)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}).
Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}).
Track("IUserWeaponAbility", oldUser, userdata.SortedWeaponAbilityRecords, []string{"userId", "userWeaponUuid", "slotNumber"})
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
weapon, ok := user.Weapons[req.UserWeaponUuid]
if !ok {
log.Printf("[WeaponService] LimitBreakByWeapon: weapon uuid=%s not found", req.UserWeaponUuid)
return
}
if weapon.LimitBreakCount >= s.config.WeaponLimitBreakAvailableCount {
log.Printf("[WeaponService] LimitBreakByWeapon: already at max limit break %d", weapon.LimitBreakCount)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] LimitBreakByWeapon: weapon master id=%d not found", weapon.WeaponId)
return
}
remaining := s.config.WeaponLimitBreakAvailableCount - weapon.LimitBreakCount
consumedCount := int32(0)
for _, uuid := range req.MaterialUserWeaponUuids {
if consumedCount >= remaining {
break
}
matWeapon, ok := user.Weapons[uuid]
if !ok {
log.Printf("[WeaponService] LimitBreakByWeapon: material weapon uuid=%s not found, skipping", uuid)
continue
}
if medals, ok := s.catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
for itemId, count := range medals {
user.ConsumableItems[itemId] += count
}
}
delete(user.Weapons, uuid)
delete(user.WeaponSkills, uuid)
delete(user.WeaponAbilities, uuid)
consumedCount++
}
if costFunc, ok := s.catalog.LimitBreakCostByWeaponByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && consumedCount > 0 {
goldCost := costFunc.Evaluate(consumedCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] LimitBreakByWeapon: gold cost=%d", goldCost)
}
weapon.LimitBreakCount += consumedCount
weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon
note := user.WeaponNotes[weapon.WeaponId]
if note.MaxLimitBreakCount < weapon.LimitBreakCount {
note.MaxLimitBreakCount = weapon.LimitBreakCount
note.LatestVersion = nowMillis
user.WeaponNotes[weapon.WeaponId] = note
}
log.Printf("[WeaponService] LimitBreakByWeapon: weaponId=%d limitBreak -> %d (consumed %d weapons)", weapon.WeaponId, weapon.LimitBreakCount, consumedCount)
})
if err != nil {
return nil, fmt.Errorf("weapon limit break by weapon: %w", err)
}
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), limitBreakDiffTables)
diff := tracker.Apply(snapshot, tables)
return &pb.LimitBreakByWeaponResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.EnhanceByWeaponRequest) (*pb.EnhanceByWeaponResponse, error) {
log.Printf("[WeaponService] EnhanceByWeapon: uuid=%s materialUuids=%v", req.UserWeaponUuid, req.MaterialUserWeaponUuids)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}).
Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}).
Track("IUserWeaponAbility", oldUser, userdata.SortedWeaponAbilityRecords, []string{"userId", "userWeaponUuid", "slotNumber"})
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
weapon, ok := user.Weapons[req.UserWeaponUuid]
if !ok {
log.Printf("[WeaponService] EnhanceByWeapon: weapon uuid=%s not found", req.UserWeaponUuid)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceByWeapon: weapon master id=%d not found", weapon.WeaponId)
return
}
totalExp := int32(0)
consumedCount := int32(0)
for _, uuid := range req.MaterialUserWeaponUuids {
matWeapon, ok := user.Weapons[uuid]
if !ok {
log.Printf("[WeaponService] EnhanceByWeapon: material weapon uuid=%s not found, skipping", uuid)
continue
}
matMaster, ok := s.catalog.Weapons[matWeapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceByWeapon: material weapon master id=%d not found, skipping", matWeapon.WeaponId)
continue
}
baseExp := s.catalog.BaseExpByEnhanceId[matMaster.WeaponSpecificEnhanceId]
if matMaster.WeaponType != 0 && matMaster.WeaponType == wm.WeaponType {
baseExp = baseExp * s.config.MaterialSameWeaponExpCoefficientPermil / 1000
}
totalExp += baseExp
if medals, ok := s.catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
for itemId, count := range medals {
user.ConsumableItems[itemId] += count
}
}
delete(user.Weapons, uuid)
delete(user.WeaponSkills, uuid)
delete(user.WeaponAbilities, uuid)
consumedCount++
}
if costFunc, ok := s.catalog.EnhanceCostByWeaponByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && consumedCount > 0 {
goldCost := costFunc.Evaluate(consumedCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] EnhanceByWeapon: gold cost=%d (weapons=%d)", goldCost, consumedCount)
}
weapon.Exp += totalExp
if thresholds, ok := s.catalog.ExpByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds)
}
weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon
log.Printf("[WeaponService] EnhanceByWeapon: weaponId=%d +%d exp -> total=%d level=%d", weapon.WeaponId, totalExp, weapon.Exp, weapon.Level)
})
if err != nil {
return nil, fmt.Errorf("weapon enhance by weapon: %w", err)
}
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), weaponDiffTables)
diff := tracker.Apply(snapshot, tables)
return &pb.EnhanceByWeaponResponse{
IsGreatSuccess: false,
SurplusEnhanceWeapon: []string{},
DiffUserData: diff,
}, nil
}
func (s *WeaponServiceServer) checkEvolutionStoryUnlocks(user *store.UserState, weaponId int32, nowMillis int64) {
wm, ok := s.catalog.Weapons[weaponId]
if !ok || wm.WeaponStoryReleaseConditionGroupId == 0 {
return
}
evoOrder, hasEvo := s.catalog.EvolutionOrder[weaponId]
conditions := s.catalog.ReleaseConditionsByGroupId[wm.WeaponStoryReleaseConditionGroupId]
for _, cond := range conditions {
switch cond.WeaponStoryReleaseConditionType {
case model.WeaponStoryReleaseConditionTypeReachSpecifiedEvolutionCount:
if hasEvo && evoOrder >= cond.ConditionValue {
store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
}
case model.WeaponStoryReleaseConditionTypeAcquisition:
store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
}
}
}