mirror of
https://github.com/Walter-Sparrow/lunar-tear.git
synced 2026-07-02 05:43:41 +03:00
Initial commit
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ×tamppb.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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;">© 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>`))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user