Initial commit

This commit is contained in:
Ilya Groshev
2026-04-14 09:28:26 +03:00
commit 02f511f40c
161 changed files with 21541 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Proto generation: outputs to gen/proto/ (module=lunar-tear/server).
# All proto files have go_package. Only protos used by the server are generated here
# (generating all would put them in one package and cause name clashes).
PROTO_USED = proto/banner.proto proto/battle.proto proto/bighunt.proto proto/cageornament.proto proto/character.proto proto/characterboard.proto proto/characterviewer.proto proto/companion.proto proto/config.proto proto/contentsstory.proto proto/costume.proto proto/data.proto proto/deck.proto proto/dokan.proto proto/explore.proto proto/friend.proto proto/gacha.proto proto/gameplay.proto proto/gift.proto proto/gimmick.proto proto/labyrinth.proto proto/loginbonus.proto proto/material.proto proto/mission.proto proto/movie.proto proto/navicutin.proto proto/omikuji.proto proto/notification.proto proto/parts.proto proto/portalcage.proto proto/pvp.proto proto/quest.proto proto/reward.proto proto/shop.proto proto/sidestoryquest.proto proto/tutorial.proto proto/user.proto proto/weapon.proto
proto:
protoc -I . $(PROTO_USED) --go_out=. --go_opt=module=lunar-tear/server --go-grpc_out=. --go-grpc_opt=module=lunar-tear/server
@echo "Generated in gen/proto/"
.PHONY: proto
+205
View File
@@ -0,0 +1,205 @@
package main
import (
"context"
"fmt"
"log"
"net"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/service"
"lunar-tear/server/internal/store/memory"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/reflection"
"google.golang.org/grpc/status"
)
type loggingListener struct {
net.Listener
}
func (l loggingListener) Accept() (net.Conn, error) {
conn, err := l.Listener.Accept()
if err != nil {
log.Printf("[gRPC] Accept error: %v", err)
return nil, err
}
log.Printf("[gRPC] New connection from %v", conn.RemoteAddr())
return conn, nil
}
func startGRPC(
host string,
octoURL string,
userStore *memory.MemoryStore,
questEngine *questflow.QuestHandler,
gachaHandler *gacha.GachaHandler,
cageOrnamentCatalog *masterdata.CageOrnamentCatalog,
loginBonusCatalog *masterdata.LoginBonusCatalog,
characterViewerCatalog *masterdata.CharacterViewerCatalog,
shopCatalog *masterdata.ShopCatalog,
costumeCatalog *masterdata.CostumeCatalog,
omikujiCatalog *masterdata.OmikujiCatalog,
weaponCatalog *masterdata.WeaponCatalog,
exploreCatalog *masterdata.ExploreCatalog,
gimmickCatalog *masterdata.GimmickCatalog,
characterBoardCatalog *masterdata.CharacterBoardCatalog,
partsCatalog *masterdata.PartsCatalog,
characterRebirthCatalog *masterdata.CharacterRebirthCatalog,
companionCatalog *masterdata.CompanionCatalog,
materialCatalog *masterdata.MaterialCatalog,
gameConfig *masterdata.GameConfig,
sideStoryCatalog *masterdata.SideStoryCatalog,
bigHuntCatalog *masterdata.BigHuntCatalog,
) {
lis, err := net.Listen("tcp", ":443")
if err != nil {
log.Fatalf("failed to listen on :443: %v", err)
}
lis = loggingListener{Listener: lis}
grpcServer := grpc.NewServer(
grpc.ChainUnaryInterceptor(loggingInterceptor, timeSyncInterceptor),
grpc.UnknownServiceHandler(loggingUnknownService),
)
registerServices(grpcServer,
host,
octoURL,
userStore,
questEngine,
gachaHandler,
cageOrnamentCatalog,
loginBonusCatalog,
characterViewerCatalog,
shopCatalog,
costumeCatalog,
omikujiCatalog,
weaponCatalog,
exploreCatalog,
gimmickCatalog,
characterBoardCatalog,
partsCatalog,
characterRebirthCatalog,
companionCatalog,
materialCatalog,
gameConfig,
sideStoryCatalog,
bigHuntCatalog,
)
reflection.Register(grpcServer)
log.Printf("gRPC server listening on :443")
log.Printf("client host address: %s:443", host)
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
func registerServices(
srv *grpc.Server,
host string,
octoURL string,
userStore *memory.MemoryStore,
questEngine *questflow.QuestHandler,
gachaHandler *gacha.GachaHandler,
cageOrnamentCatalog *masterdata.CageOrnamentCatalog,
loginBonusCatalog *masterdata.LoginBonusCatalog,
characterViewerCatalog *masterdata.CharacterViewerCatalog,
shopCatalog *masterdata.ShopCatalog,
costumeCatalog *masterdata.CostumeCatalog,
omikujiCatalog *masterdata.OmikujiCatalog,
weaponCatalog *masterdata.WeaponCatalog,
exploreCatalog *masterdata.ExploreCatalog,
gimmickCatalog *masterdata.GimmickCatalog,
characterBoardCatalog *masterdata.CharacterBoardCatalog,
partsCatalog *masterdata.PartsCatalog,
characterRebirthCatalog *masterdata.CharacterRebirthCatalog,
companionCatalog *masterdata.CompanionCatalog,
materialCatalog *masterdata.MaterialCatalog,
gameConfig *masterdata.GameConfig,
sideStoryCatalog *masterdata.SideStoryCatalog,
bigHuntCatalog *masterdata.BigHuntCatalog,
) {
pb.RegisterBannerServiceServer(srv, service.NewBannerServiceServer(userStore))
pb.RegisterUserServiceServer(srv, service.NewUserServiceServer(userStore, userStore))
pb.RegisterBattleServiceServer(srv, service.NewBattleServiceServer(userStore, userStore))
pb.RegisterConfigServiceServer(srv, service.NewConfigServiceServer(host, int32(443), octoURL))
pb.RegisterDataServiceServer(srv, service.NewDataServiceServer(userStore, userStore))
pb.RegisterTutorialServiceServer(srv, service.NewTutorialServiceServer(userStore, userStore, questEngine))
pb.RegisterGachaServiceServer(srv, service.NewGachaServiceServer(userStore, userStore, userStore, gachaHandler))
pb.RegisterGiftServiceServer(srv, service.NewGiftServiceServer(userStore, userStore))
pb.RegisterGamePlayServiceServer(srv, service.NewGameplayServiceServer())
pb.RegisterGimmickServiceServer(srv, service.NewGimmickServiceServer(userStore, userStore, gimmickCatalog))
pb.RegisterQuestServiceServer(srv, service.NewQuestServiceServer(userStore, userStore, questEngine))
pb.RegisterNotificationServiceServer(srv, service.NewNotificationServiceServer(userStore, userStore))
pb.RegisterCageOrnamentServiceServer(srv, service.NewCageOrnamentServiceServer(userStore, userStore, cageOrnamentCatalog, questEngine.Granter))
pb.RegisterDeckServiceServer(srv, service.NewDeckServiceServer(userStore, userStore))
pb.RegisterFriendServiceServer(srv, service.NewFriendServiceServer(userStore, userStore))
pb.RegisterLoginBonusServiceServer(srv, service.NewLoginBonusServiceServer(userStore, userStore, loginBonusCatalog))
pb.RegisterNaviCutInServiceServer(srv, service.NewNaviCutInServiceServer(userStore, userStore))
pb.RegisterContentsStoryServiceServer(srv, service.NewContentsStoryServiceServer(userStore, userStore))
pb.RegisterDokanServiceServer(srv, service.NewDokanServiceServer(userStore, userStore))
pb.RegisterPortalCageServiceServer(srv, service.NewPortalCageServiceServer(userStore, userStore))
pb.RegisterCharacterViewerServiceServer(srv, service.NewCharacterViewerServiceServer(userStore, userStore, characterViewerCatalog))
pb.RegisterMissionServiceServer(srv, service.NewMissionServiceServer(userStore, userStore))
pb.RegisterShopServiceServer(srv, service.NewShopServiceServer(userStore, userStore, shopCatalog, questEngine.Granter))
pb.RegisterCostumeServiceServer(srv, service.NewCostumeServiceServer(userStore, userStore, costumeCatalog, gameConfig))
pb.RegisterMovieServiceServer(srv, service.NewMovieServiceServer(userStore, userStore))
pb.RegisterOmikujiServiceServer(srv, service.NewOmikujiServiceServer(userStore, userStore, omikujiCatalog))
pb.RegisterWeaponServiceServer(srv, service.NewWeaponServiceServer(userStore, userStore, weaponCatalog, gameConfig))
pb.RegisterExploreServiceServer(srv, service.NewExploreServiceServer(userStore, userStore, exploreCatalog))
pb.RegisterCharacterBoardServiceServer(srv, service.NewCharacterBoardServiceServer(userStore, userStore, characterBoardCatalog))
pb.RegisterPartsServiceServer(srv, service.NewPartsServiceServer(userStore, userStore, partsCatalog, gameConfig))
pb.RegisterCharacterServiceServer(srv, service.NewCharacterServiceServer(userStore, userStore, characterRebirthCatalog, gameConfig))
pb.RegisterCompanionServiceServer(srv, service.NewCompanionServiceServer(userStore, userStore, companionCatalog, gameConfig))
pb.RegisterMaterialServiceServer(srv, service.NewMaterialServiceServer(userStore, userStore, materialCatalog, gameConfig))
pb.RegisterSideStoryQuestServiceServer(srv, service.NewSideStoryQuestServiceServer(userStore, userStore, sideStoryCatalog))
pb.RegisterBigHuntServiceServer(srv, service.NewBigHuntServiceServer(userStore, userStore, bigHuntCatalog, questEngine))
pb.RegisterRewardServiceServer(srv, service.NewRewardServiceServer(userStore, userStore, bigHuntCatalog, questEngine.Granter))
}
func loggingInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
log.Printf(">>> %s", info.FullMethod)
resp, err := handler(ctx, req)
if err != nil {
log.Printf("<<< %s ERROR: %v", info.FullMethod, err)
} else {
log.Printf("<<< %s OK", info.FullMethod)
}
return resp, err
}
func timeSyncInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
resp, err := handler(ctx, req)
switch info.FullMethod {
case "/apb.api.user.UserService/Auth",
"/apb.api.user.UserService/RegisterUser",
"/apb.api.user.UserService/TransferUser":
default:
grpc.SetTrailer(ctx, metadata.Pairs(
"x-apb-response-datetime", fmt.Sprintf("%d", gametime.NowMillis()),
))
}
return resp, err
}
func loggingUnknownService(_ any, stream grpc.ServerStream) error {
fullMethod, ok := grpc.MethodFromServerStream(stream)
if !ok {
fullMethod = "<unknown>"
}
log.Printf(">>> %s", fullMethod)
err := status.Errorf(codes.Unimplemented, "unknown service or method %s", fullMethod)
log.Printf("<<< %s ERROR: %v", fullMethod, err)
return err
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"fmt"
"log"
"net/http"
"lunar-tear/server/internal/service"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
func startHTTP(port int, resourcesBaseURL string) {
octoServer := service.NewOctoHTTPServer(resourcesBaseURL)
h2s := &http2.Server{}
octoHandler := h2c.NewHandler(octoServer.Handler(), h2s)
log.Printf("Octo HTTP server listening on :%d (HTTP/1.1 + h2c)", port)
srv := &http.Server{Addr: fmt.Sprintf(":%d", port), Handler: octoHandler}
http2.ConfigureServer(srv, h2s)
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("HTTP server on %d failed: %v", port, err)
}
}
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"flag"
"log"
"os"
"strconv"
"strings"
"lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store/memory"
)
func main() {
httpPort := flag.Int("http-port", 8080, "HTTP server port (Octo API)")
host := flag.String("host", "127.0.0.1", "hostname the client will connect to")
scene := flag.Int("scene", 0, "Bootstrap to scene N (0 = fresh start)")
starterItems := flag.Bool("starter-items", false, "Grant starter items to new users")
flag.Parse()
octoURL := "http://" + *host + ":" + strconv.Itoa(*httpPort)
prefix := octoURL + "/"
padLen := 43 - len(prefix)
resourcesBaseURL := ""
if padLen < 1 {
log.Printf("[config] host:port too long for 43-char resource URL; list.bin will be served unchanged")
} else {
resourcesBaseURL = prefix + strings.Repeat("r", padLen)
}
go startHTTP(*httpPort, resourcesBaseURL)
snapshotDir := "snapshots"
if err := os.MkdirAll(snapshotDir, 0755); err != nil {
log.Fatalf("create snapshot dir: %v", err)
}
gameConfig, err := masterdata.LoadGameConfig()
if err != nil {
log.Fatalf("load game config: %v", err)
}
log.Printf("game config loaded (goldId=%d, skipTicketId=%d, rebirthGold=%d)",
gameConfig.ConsumableItemIdForGold, gameConfig.ConsumableItemIdForQuestSkipTicket, gameConfig.CharacterRebirthConsumeGold)
partsCatalog, err := masterdata.LoadPartsCatalog()
if err != nil {
log.Fatalf("load parts catalog: %v", err)
}
log.Printf("parts catalog loaded: %d parts, %d rarities", len(partsCatalog.PartsById), len(partsCatalog.RarityByRarityType))
questCatalog, err := masterdata.LoadQuestCatalog(partsCatalog)
if err != nil {
log.Fatalf("load quest catalog: %v", err)
}
questHandler := questflow.NewQuestHandler(questCatalog, gameConfig)
userStore := memory.New(gametime.Now,
memory.WithSnapshotDir(snapshotDir),
memory.WithSceneId(int32(*scene)),
memory.WithStarterItems(*starterItems),
)
if *scene != 0 {
log.Printf("bootstrap scene: %d (from snapshot)", *scene)
}
gachaEntries, medalInfo, err := masterdata.LoadGachaCatalog()
if err != nil {
log.Fatalf("load gacha catalog: %v", err)
}
log.Printf("gacha catalog loaded: %d entries", len(gachaEntries))
gachaPool, err := masterdata.LoadGachaPool()
if err != nil {
log.Fatalf("load gacha pool: %v", err)
}
log.Printf("gacha pool loaded: costumes=%d rarities, weapons=%d rarities, materials=%d",
len(gachaPool.CostumesByRarity), len(gachaPool.WeaponsByRarity), len(gachaPool.Materials))
shopCatalog, err := masterdata.LoadShopCatalog()
if err != nil {
log.Fatalf("load shop catalog: %v", err)
}
log.Printf("shop catalog loaded: %d items, %d content groups, %d exchange shops",
len(shopCatalog.Items), len(shopCatalog.Contents), len(shopCatalog.ExchangeShopCells))
gachaPool.BuildShopFeatured(shopCatalog)
gachaPool.PruneUnpairedCostumes()
gachaPool.BuildFeaturedMapping(gachaEntries)
gachaPool.BuildBannerPools(gachaEntries)
masterdata.EnrichCatalogPromotions(gachaEntries, gachaPool)
userStore.ReplaceCatalog(gachaEntries)
dupExchange, err := masterdata.LoadDupExchange()
if err != nil {
log.Fatalf("load dup exchange: %v", err)
}
dupAdded, err := masterdata.EnrichDupExchange(dupExchange, gachaPool)
if err != nil {
log.Fatalf("enrich dup exchange: %v", err)
}
log.Printf("dup exchange loaded: %d entries (%d derived from limit-break materials)", len(dupExchange), dupAdded)
gachaHandler := gacha.NewGachaHandler(gachaPool, gameConfig, questHandler.Granter, medalInfo, dupExchange)
conditionResolver, err := masterdata.LoadConditionResolver()
if err != nil {
log.Fatalf("load condition resolver: %v", err)
}
cageOrnamentCatalog := masterdata.LoadCageOrnamentCatalog()
loginBonusCatalog := masterdata.LoadLoginBonusCatalog()
characterViewerCatalog := masterdata.LoadCharacterViewerCatalog(conditionResolver)
omikujiCatalog := masterdata.LoadOmikujiCatalog()
materialCatalog, err := masterdata.LoadMaterialCatalog()
if err != nil {
log.Fatalf("load material catalog: %v", err)
}
log.Printf("material catalog loaded: %d materials", len(materialCatalog.All))
costumeCatalog, err := masterdata.LoadCostumeCatalog(materialCatalog)
if err != nil {
log.Fatalf("load costume catalog: %v", err)
}
log.Printf("costume catalog loaded: %d costumes, %d materials, %d rarity curves", len(costumeCatalog.Costumes), len(costumeCatalog.Materials), len(costumeCatalog.ExpByRarity))
weaponCatalog, err := masterdata.LoadWeaponCatalog(materialCatalog)
if err != nil {
log.Fatalf("load weapon catalog: %v", err)
}
log.Printf("weapon catalog loaded: %d weapons, %d materials, %d enhance configs", len(weaponCatalog.Weapons), len(weaponCatalog.Materials), len(weaponCatalog.ExpByEnhanceId))
exploreCatalog, err := masterdata.LoadExploreCatalog()
if err != nil {
log.Fatalf("load explore catalog: %v", err)
}
log.Printf("explore catalog loaded: %d explores, %d grade assets", len(exploreCatalog.Explores), len(exploreCatalog.GradeAssets))
gimmickCatalog, err := masterdata.LoadGimmickCatalog(conditionResolver)
if err != nil {
log.Fatalf("load gimmick catalog: %v", err)
}
characterBoardCatalog, err := masterdata.LoadCharacterBoardCatalog()
if err != nil {
log.Fatalf("load character board catalog: %v", err)
}
log.Printf("character board catalog loaded: %d panels, %d boards", len(characterBoardCatalog.PanelById), len(characterBoardCatalog.BoardById))
characterRebirthCatalog, err := masterdata.LoadCharacterRebirthCatalog()
if err != nil {
log.Fatalf("load character rebirth catalog: %v", err)
}
log.Printf("character rebirth catalog loaded: %d characters", len(characterRebirthCatalog.StepGroupByCharacterId))
companionCatalog, err := masterdata.LoadCompanionCatalog()
if err != nil {
log.Fatalf("load companion catalog: %v", err)
}
log.Printf("companion catalog loaded: %d companions, %d categories", len(companionCatalog.CompanionById), len(companionCatalog.GoldCostByCategory))
sideStoryCatalog := masterdata.LoadSideStoryCatalog()
bigHuntCatalog := masterdata.LoadBigHuntCatalog()
startGRPC(
*host,
octoURL,
userStore,
questHandler,
gachaHandler,
cageOrnamentCatalog,
loginBonusCatalog,
characterViewerCatalog,
shopCatalog,
costumeCatalog,
omikujiCatalog,
weaponCatalog,
exploreCatalog,
gimmickCatalog,
characterBoardCatalog,
partsCatalog,
characterRebirthCatalog,
companionCatalog,
materialCatalog,
gameConfig,
sideStoryCatalog,
bigHuntCatalog,
)
}
+17
View File
@@ -0,0 +1,17 @@
module lunar-tear/server
go 1.24.2
require (
google.golang.org/grpc v1.79.1
google.golang.org/protobuf v1.36.11
)
require (
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
)
+42
View File
@@ -0,0 +1,42 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+223
View File
@@ -0,0 +1,223 @@
package gacha
import (
"log"
"math/rand"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
)
type RateTier struct {
Weight int
PossessionType int32
RarityType model.RarityType
}
type DrawnItem struct {
PossessionType int32
PossessionId int32
RarityType model.RarityType
CharacterId int32
}
var premiumRates = []RateTier{
{200, int32(model.PossessionTypeCostume), model.RaritySSRare},
{300, int32(model.PossessionTypeWeapon), model.RaritySSRare},
{500, int32(model.PossessionTypeCostume), model.RaritySRare},
{1000, int32(model.PossessionTypeWeapon), model.RaritySRare},
{8000, int32(model.PossessionTypeWeapon), model.RarityRare},
}
func DrawPremium(bp *masterdata.BannerPool, count int, fixedRarityMin int32, fixedCount int, rateMultiplier float64) []DrawnItem {
result := make([]DrawnItem, 0, count)
rates := adjustRates(premiumRates, rateMultiplier)
totalWeight := 0
for _, r := range rates {
totalWeight += r.Weight
}
for i := range count {
isGuaranteeSlot := fixedCount > 0 && i >= count-fixedCount
item := rollOne(bp, rates, totalWeight)
if isGuaranteeSlot && item.RarityType < fixedRarityMin {
item = rollAtMinRarity(bp, rates, fixedRarityMin)
}
result = append(result, item)
}
return result
}
func DrawBox(items []BoxItem, count int) []DrawnItem {
var available []int
for i, item := range items {
remaining := item.MaxCount - item.DrewCount
for range remaining {
available = append(available, i)
}
}
result := make([]DrawnItem, 0, count)
for i := 0; i < count && len(available) > 0; i++ {
pick := rand.Intn(len(available))
idx := available[pick]
item := items[idx]
result = append(result, DrawnItem{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
RarityType: item.RarityType,
})
items[idx].DrewCount++
available = append(available[:pick], available[pick+1:]...)
}
return result
}
func DrawReward(materials []masterdata.GachaPoolItem, count int) []DrawnItem {
if len(materials) == 0 {
return nil
}
result := make([]DrawnItem, 0, count)
for range count {
m := materials[rand.Intn(len(materials))]
result = append(result, DrawnItem{
PossessionType: m.PossessionType,
PossessionId: m.PossessionId,
RarityType: m.RarityType,
})
}
return result
}
type BoxItem struct {
PossessionType int32
PossessionId int32
RarityType model.RarityType
Count int32
MaxCount int32
DrewCount int32
IsTarget bool
}
func adjustRates(base []RateTier, multiplier float64) []RateTier {
if multiplier == 1.0 || multiplier == 0 {
return base
}
adjusted := make([]RateTier, len(base))
copy(adjusted, base)
var fourStarExtra int
var nonFourStar int
for i, r := range adjusted {
if r.RarityType >= model.RaritySSRare {
extra := int(float64(r.Weight) * (multiplier - 1.0))
adjusted[i].Weight += extra
fourStarExtra += extra
} else {
nonFourStar += r.Weight
}
}
if nonFourStar > 0 && fourStarExtra > 0 {
for i, r := range adjusted {
if r.RarityType < model.RaritySSRare {
reduction := fourStarExtra * r.Weight / nonFourStar
adjusted[i].Weight -= reduction
if adjusted[i].Weight < 1 {
adjusted[i].Weight = 1
}
}
}
}
return adjusted
}
func rollOne(bp *masterdata.BannerPool, rates []RateTier, totalWeight int) DrawnItem {
roll := rand.Intn(totalWeight)
cumulative := 0
var tier RateTier
for _, r := range rates {
cumulative += r.Weight
if roll < cumulative {
tier = r
break
}
}
if item, ok := tryFeaturedRateUp(bp, tier); ok {
return item
}
return pickFromPool(bp, tier.PossessionType, tier.RarityType)
}
func tryFeaturedRateUp(bp *masterdata.BannerPool, tier RateTier) (DrawnItem, bool) {
var matches []masterdata.GachaPoolItem
for _, f := range bp.Featured {
if f.PossessionType == tier.PossessionType && f.RarityType == tier.RarityType {
matches = append(matches, f)
}
}
if len(matches) == 0 {
return DrawnItem{}, false
}
if rand.Intn(model.FeaturedRateUpDenom) >= model.FeaturedRateUpPercent {
return DrawnItem{}, false
}
f := matches[rand.Intn(len(matches))]
return DrawnItem{
PossessionType: f.PossessionType,
PossessionId: f.PossessionId,
RarityType: f.RarityType,
CharacterId: f.CharacterId,
}, true
}
func rollAtMinRarity(bp *masterdata.BannerPool, rates []RateTier, minRarity model.RarityType) DrawnItem {
var filtered []RateTier
filteredTotal := 0
for _, r := range rates {
if r.RarityType >= minRarity {
filtered = append(filtered, r)
filteredTotal += r.Weight
}
}
if filteredTotal == 0 {
return pickFromPool(bp, int32(model.PossessionTypeWeapon), minRarity)
}
return rollOne(bp, filtered, filteredTotal)
}
func pickFromPool(bp *masterdata.BannerPool, possessionType int32, rarityType model.RarityType) DrawnItem {
if possessionType == int32(model.PossessionTypeCostume) {
items := bp.CostumesByRarity[rarityType]
if len(items) == 0 {
items = bp.CostumesByRarity[model.RaritySSRare]
}
if len(items) == 0 {
log.Printf("[pickFromPool] empty costume pool for rarity=%d, returning phantom item", rarityType)
return DrawnItem{PossessionType: int32(model.PossessionTypeWeapon), RarityType: rarityType}
}
pick := items[rand.Intn(len(items))]
return DrawnItem{
PossessionType: pick.PossessionType,
PossessionId: pick.PossessionId,
RarityType: pick.RarityType,
CharacterId: pick.CharacterId,
}
}
items := bp.WeaponsByRarity[rarityType]
if len(items) == 0 {
items = bp.WeaponsByRarity[model.RarityRare]
}
if len(items) == 0 {
log.Printf("[pickFromPool] empty weapon pool for rarity=%d, returning phantom item", rarityType)
return DrawnItem{PossessionType: int32(model.PossessionTypeWeapon), RarityType: rarityType}
}
pick := items[rand.Intn(len(items))]
return DrawnItem{
PossessionType: pick.PossessionType,
PossessionId: pick.PossessionId,
RarityType: pick.RarityType,
}
}
+342
View File
@@ -0,0 +1,342 @@
package gacha
import (
"fmt"
"log"
"math/rand"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
)
type DrawResult struct {
Items []DrawnItem
BonusItems map[int]DrawnItem
Bonuses []store.GachaBonusEntry
DuplicateInfos []DuplicateInfo
BonusDuplicateInfos []DuplicateInfo
MedalBonus int32
}
type DuplicateInfo struct {
Index int
Grade int32
Bonuses []model.DupExchangeEntry
}
type GachaHandler struct {
Pool *masterdata.GachaCatalog
Config *masterdata.GameConfig
Granter *store.PossessionGranter
MedalInfo map[int32]masterdata.GachaMedalInfo
DupExchange map[int32][]model.DupExchangeEntry
}
func NewGachaHandler(
pool *masterdata.GachaCatalog,
config *masterdata.GameConfig,
granter *store.PossessionGranter,
medalInfo map[int32]masterdata.GachaMedalInfo,
dupExchange map[int32][]model.DupExchangeEntry,
) *GachaHandler {
return &GachaHandler{
Pool: pool,
Config: config,
Granter: granter,
MedalInfo: medalInfo,
DupExchange: dupExchange,
}
}
func (h *GachaHandler) HandleDraw(
user *store.UserState,
entry store.GachaCatalogEntry,
phaseId int32,
execCount int32,
) (*DrawResult, error) {
phase, err := findPhase(entry, phaseId)
if err != nil {
return nil, err
}
totalCost := phase.Price * execCount
if totalCost > 0 {
if err := store.DeductPrice(user, phase.PriceType, phase.PriceId, totalCost); err != nil {
log.Printf("[GachaHandler] DeductPrice failed (proceeding): %v", err)
}
}
drawCount := int(phase.DrawCount * execCount)
nowMillis := gametime.NowMillis()
bs := user.Gacha.BannerStates[entry.GachaId]
bs.GachaId = entry.GachaId
var items []DrawnItem
switch entry.GachaLabelType {
case model.GachaLabelPremium:
items = h.drawPremium(entry, phase, drawCount)
case model.GachaLabelChapter, model.GachaLabelRecycle:
items = h.drawMaterial(drawCount)
case model.GachaLabelEvent:
items = h.drawBox(&bs, drawCount)
default:
items = h.drawPremium(entry, phase, drawCount)
}
if entry.GachaModeType == model.GachaModeStepup {
bs.StepNumber++
if bs.StepNumber > entry.MaxStepNumber {
bs.StepNumber = 1
bs.LoopCount++
}
}
var medalBonus int32
if entry.GachaMedalId != 0 {
medalBonus = int32(drawCount)
bs.MedalCount += medalBonus
if bs.MedalCount > model.MedalCountCap {
bs.MedalCount = model.MedalCountCap
}
}
bs.DrawCount += int32(drawCount)
user.Gacha.BannerStates[entry.GachaId] = bs
dupInfos := h.grantItems(user, items, nowMillis)
bonusMap := h.generateBonusItems(entry, items)
bonusSlice := make([]DrawnItem, 0, len(bonusMap))
for _, b := range bonusMap {
bonusSlice = append(bonusSlice, b)
}
bonusDupInfos := h.grantItems(user, bonusSlice, nowMillis)
result := &DrawResult{
Items: items,
BonusItems: bonusMap,
DuplicateInfos: dupInfos,
BonusDuplicateInfos: bonusDupInfos,
MedalBonus: medalBonus,
}
for _, p := range phase.Bonuses {
store.GrantPossession(user, model.PossessionType(p.PossessionType), p.PossessionId, p.Count)
result.Bonuses = append(result.Bonuses, p)
}
if medalBonus > 0 && entry.MedalConsumableItemId != 0 {
store.GrantPossession(user, model.PossessionTypeConsumableItem, entry.MedalConsumableItemId, medalBonus)
}
return result, nil
}
func (h *GachaHandler) HandleResetBox(
user *store.UserState,
entry store.GachaCatalogEntry,
) error {
bs := user.Gacha.BannerStates[entry.GachaId]
bs.BoxDrewCounts = make(map[int32]int32)
bs.BoxNumber++
user.Gacha.BannerStates[entry.GachaId] = bs
return nil
}
func clampDailyDraw(lastDate, todayStart int64, currentCount, maxCount, requested int32) (clamped, newCount int32, reset bool) {
if lastDate < todayStart {
currentCount = 0
reset = true
}
remaining := maxCount - currentCount
if remaining <= 0 {
return 0, currentCount, reset
}
if requested > remaining {
requested = remaining
}
return requested, currentCount + requested, reset
}
func (h *GachaHandler) HandleRewardDraw(
user *store.UserState,
count int32,
) ([]DrawnItem, error) {
nowMillis := gametime.NowMillis()
todayStart := gametime.StartOfDayMillis()
maxCount := h.Config.RewardGachaDailyMaxCount
if maxCount <= 0 {
maxCount = model.DefaultDailyDrawLimit
}
clamped, newCount, _ := clampDailyDraw(
user.Gacha.LastRewardDrawDate, todayStart,
user.Gacha.TodaysCurrentDrawCount, maxCount, count,
)
if clamped <= 0 {
return nil, fmt.Errorf("daily reward draw limit reached")
}
items := DrawReward(h.Pool.Materials, int(clamped))
for _, item := range items {
store.GrantPossession(user, model.PossessionType(item.PossessionType), item.PossessionId, 1)
}
user.Gacha.TodaysCurrentDrawCount = newCount
user.Gacha.DailyMaxCount = maxCount
user.Gacha.LastRewardDrawDate = nowMillis
user.Gacha.RewardAvailable = newCount < maxCount
return items, nil
}
func (h *GachaHandler) drawPremium(entry store.GachaCatalogEntry, phase store.GachaPricePhaseEntry, count int) []DrawnItem {
fixedMin := phase.FixedRarityMin
fixedCount := int(phase.FixedCount)
bp := h.Pool.BannerPools[entry.GachaId]
if bp == nil {
bp = &masterdata.BannerPool{
CostumesByRarity: h.Pool.CostumesByRarity,
WeaponsByRarity: h.Pool.WeaponsByRarity,
}
}
rateMultiplier := 1.0
if entry.GachaModeType == model.GachaModeStepup {
switch phase.StepNumber {
case 1, 3:
rateMultiplier = model.StepUpRateBoost
case 5:
rateMultiplier = model.StepUpRateMaxBoost
}
}
return DrawPremium(bp, count, fixedMin, fixedCount, rateMultiplier)
}
func (h *GachaHandler) drawMaterial(count int) []DrawnItem {
return DrawReward(h.Pool.Materials, count)
}
func (h *GachaHandler) drawBox(bs *store.GachaBannerState, count int) []DrawnItem {
if bs.BoxDrewCounts == nil {
bs.BoxDrewCounts = make(map[int32]int32)
}
boxItems := h.buildBoxPool()
for i := range boxItems {
boxItems[i].DrewCount = bs.BoxDrewCounts[boxItems[i].PossessionId]
}
result := DrawBox(boxItems, count)
for _, item := range result {
bs.BoxDrewCounts[item.PossessionId]++
}
return result
}
func (h *GachaHandler) buildBoxPool() []BoxItem {
var items []BoxItem
for _, mat := range h.Pool.Materials {
items = append(items, BoxItem{
PossessionType: mat.PossessionType,
PossessionId: mat.PossessionId,
RarityType: mat.RarityType,
Count: 1,
MaxCount: model.BoxItemDefaultMax,
})
if len(items) >= model.BoxPoolMaxItems {
break
}
}
if len(items) < model.BoxPoolMinItems {
items = append(items, BoxItem{
PossessionType: int32(model.PossessionTypeMaterial),
PossessionId: model.BoxFallbackItemId,
RarityType: model.RarityNormal,
Count: 1,
MaxCount: model.BoxFallbackItemMax,
})
}
return items
}
func (h *GachaHandler) grantItems(user *store.UserState, items []DrawnItem, nowMillis int64) []DuplicateInfo {
var dupInfos []DuplicateInfo
for i, item := range items {
switch model.PossessionType(item.PossessionType) {
case model.PossessionTypeCostume:
if dup, ok := h.tryCostumeDupExchange(user, item, i); ok {
dupInfos = append(dupInfos, dup)
continue
}
h.Granter.GrantCostume(user, item.PossessionId, nowMillis)
case model.PossessionTypeWeapon:
h.Granter.GrantWeapon(user, item.PossessionId, nowMillis)
default:
if item.PossessionType != 0 {
store.GrantPossession(user, model.PossessionType(item.PossessionType), item.PossessionId, 1)
}
}
}
return dupInfos
}
func (h *GachaHandler) tryCostumeDupExchange(user *store.UserState, item DrawnItem, index int) (DuplicateInfo, bool) {
for _, c := range user.Costumes {
if c.CostumeId == item.PossessionId {
grade := int32(rand.Intn(model.DupGradeRange) + int(model.DupGradeMin))
exchanges := h.DupExchange[item.PossessionId]
for _, ex := range exchanges {
store.GrantPossession(user, model.PossessionType(ex.PossessionType), ex.PossessionId, ex.Count)
}
return DuplicateInfo{Index: index, Grade: grade, Bonuses: exchanges}, true
}
}
return DuplicateInfo{}, false
}
func (h *GachaHandler) generateBonusItems(entry store.GachaCatalogEntry, mainItems []DrawnItem) map[int]DrawnItem {
bonus := make(map[int]DrawnItem)
for i, item := range mainItems {
if item.PossessionType != int32(model.PossessionTypeCostume) {
continue
}
wid, ok := h.Pool.CostumeWeaponMap[item.PossessionId]
if !ok {
continue
}
w, ok := h.Pool.WeaponById[wid]
if !ok {
continue
}
bonus[i] = DrawnItem{
PossessionType: w.PossessionType,
PossessionId: w.PossessionId,
RarityType: w.RarityType,
}
}
return bonus
}
func findPhase(entry store.GachaCatalogEntry, phaseId int32) (store.GachaPricePhaseEntry, error) {
for _, p := range entry.PricePhases {
if p.PhaseId == phaseId {
return p, nil
}
}
if len(entry.PricePhases) > 0 {
log.Printf("[GachaHandler] phase %d not found for gacha %d, using first phase", phaseId, entry.GachaId)
return entry.PricePhases[0], nil
}
return store.GachaPricePhaseEntry{}, fmt.Errorf("no price phases for gacha %d", entry.GachaId)
}
+27
View File
@@ -0,0 +1,27 @@
package gametime
import "time"
func Now() time.Time {
return time.Now().UTC()
}
func NowMillis() int64 {
return Now().UnixMilli()
}
func StartOfDayMillis() int64 {
n := Now()
return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
}
// WeeklyVersion returns a stable weekly identifier (start-of-week timestamp in millis, Monday 00:00 UTC).
func WeeklyVersion(millis int64) int64 {
t := time.UnixMilli(millis).UTC()
weekday := int(t.Weekday())
if weekday == 0 {
weekday = 7
}
monday := time.Date(t.Year(), t.Month(), t.Day()-(weekday-1), 0, 0, 0, 0, time.UTC)
return monday.UnixMilli()
}
+19
View File
@@ -0,0 +1,19 @@
package gameutil
// LevelAndCap returns the level for the given cumulative EXP based on
// the thresholds slice, and clamps EXP so it never exceeds the last
// threshold (max-level cap).
func LevelAndCap(exp int32, thresholds []int32) (level, capped int32) {
level = 1
for lvl := 1; lvl < len(thresholds); lvl++ {
if exp >= thresholds[lvl] {
level = int32(lvl)
} else {
break
}
}
if len(thresholds) > 0 && exp > thresholds[len(thresholds)-1] {
exp = thresholds[len(thresholds)-1]
}
return level, exp
}
+366
View File
@@ -0,0 +1,366 @@
package masterdata
import (
"log"
"sort"
"time"
"lunar-tear/server/internal/utils"
)
type bigHuntBossQuestRow struct {
BigHuntBossQuestId int32 `json:"BigHuntBossQuestId"`
BigHuntBossId int32 `json:"BigHuntBossId"`
BigHuntQuestGroupId int32 `json:"BigHuntQuestGroupId"`
BigHuntBossQuestScoreCoefficientId int32 `json:"BigHuntBossQuestScoreCoefficientId"`
BigHuntScoreRewardGroupScheduleId int32 `json:"BigHuntScoreRewardGroupScheduleId"`
DailyChallengeCount int32 `json:"DailyChallengeCount"`
}
type BigHuntBossQuestRow struct {
BigHuntBossQuestId int32
BigHuntBossId int32
BigHuntQuestGroupId int32
BigHuntScoreRewardGroupScheduleId int32
DailyChallengeCount int32
}
type bigHuntQuestRow struct {
BigHuntQuestId int32 `json:"BigHuntQuestId"`
QuestId int32 `json:"QuestId"`
BigHuntQuestScoreCoefficientId int32 `json:"BigHuntQuestScoreCoefficientId"`
}
type BigHuntQuestRow struct {
BigHuntQuestId int32
QuestId int32
BigHuntQuestScoreCoefficientId int32
}
type bigHuntQuestScoreCoefficientRow struct {
BigHuntQuestScoreCoefficientId int32 `json:"BigHuntQuestScoreCoefficientId"`
ScoreDifficultBonusPermil int32 `json:"ScoreDifficultBonusPermil"`
}
type bigHuntBossRow struct {
BigHuntBossId int32 `json:"BigHuntBossId"`
BigHuntBossGradeGroupId int32 `json:"BigHuntBossGradeGroupId"`
AttributeType int32 `json:"AttributeType"`
}
type BigHuntBossRow struct {
BigHuntBossId int32
BigHuntBossGradeGroupId int32
AttributeType int32
}
type bigHuntBossGradeGroupRow struct {
BigHuntBossGradeGroupId int32 `json:"BigHuntBossGradeGroupId"`
NecessaryScore int64 `json:"NecessaryScore"`
AssetGradeIconId int32 `json:"AssetGradeIconId"`
}
type GradeThreshold struct {
NecessaryScore int64
AssetGradeIconId int32
}
type bigHuntScheduleRow struct {
BigHuntScheduleId int32 `json:"BigHuntScheduleId"`
ChallengeStartDatetime int64 `json:"ChallengeStartDatetime"`
ChallengeEndDatetime int64 `json:"ChallengeEndDatetime"`
}
type scoreRewardScheduleRow struct {
BigHuntScoreRewardGroupScheduleId int32 `json:"BigHuntScoreRewardGroupScheduleId"`
GroupIndex int32 `json:"GroupIndex"`
BigHuntScoreRewardGroupId int32 `json:"BigHuntScoreRewardGroupId"`
StartDatetime int64 `json:"StartDatetime"`
}
type ScoreRewardScheduleEntry struct {
BigHuntScoreRewardGroupId int32
StartDatetime int64
}
type scoreRewardGroupRow struct {
BigHuntScoreRewardGroupId int32 `json:"BigHuntScoreRewardGroupId"`
NecessaryScore int64 `json:"NecessaryScore"`
BigHuntRewardGroupId int32 `json:"BigHuntRewardGroupId"`
}
type ScoreRewardThreshold struct {
NecessaryScore int64
BigHuntRewardGroupId int32
}
type rewardGroupRow struct {
BigHuntRewardGroupId int32 `json:"BigHuntRewardGroupId"`
SortOrder int32 `json:"SortOrder"`
PossessionType int32 `json:"PossessionType"`
PossessionId int32 `json:"PossessionId"`
Count int32 `json:"Count"`
}
type RewardItem struct {
PossessionType int32
PossessionId int32
Count int32
}
type weeklyRewardScheduleRow struct {
BigHuntWeeklyAttributeScoreRewardGroupScheduleId int32 `json:"BigHuntWeeklyAttributeScoreRewardGroupScheduleId"`
AttributeType int32 `json:"AttributeType"`
GroupIndex int32 `json:"GroupIndex"`
BigHuntScoreRewardGroupId int32 `json:"BigHuntScoreRewardGroupId"`
StartDatetime int64 `json:"StartDatetime"`
}
type BigHuntWeeklyRewardKey struct {
ScheduleId int32
AttributeType int32
}
type BigHuntCatalog struct {
BossQuestById map[int32]BigHuntBossQuestRow
QuestById map[int32]BigHuntQuestRow
ScoreCoefficients map[int32]int32
BossByBossId map[int32]BigHuntBossRow
GradeThresholds map[int32][]GradeThreshold
ActiveScheduleId int32
ScoreRewardSchedules map[int32][]ScoreRewardScheduleEntry
ScoreRewardThresholds map[int32][]ScoreRewardThreshold
RewardItems map[int32][]RewardItem
WeeklyRewardSchedules map[BigHuntWeeklyRewardKey][]ScoreRewardScheduleEntry
}
func (c *BigHuntCatalog) ResolveActiveScoreRewardGroupId(scheduleId int32, nowMillis int64) int32 {
entries := c.ScoreRewardSchedules[scheduleId]
for _, e := range entries {
if nowMillis >= e.StartDatetime {
return e.BigHuntScoreRewardGroupId
}
}
if len(entries) > 0 {
return entries[len(entries)-1].BigHuntScoreRewardGroupId
}
return 0
}
func (c *BigHuntCatalog) ResolveActiveWeeklyRewardGroupId(key BigHuntWeeklyRewardKey, nowMillis int64) int32 {
entries := c.WeeklyRewardSchedules[key]
for _, e := range entries {
if nowMillis >= e.StartDatetime {
return e.BigHuntScoreRewardGroupId
}
}
if len(entries) > 0 {
return entries[len(entries)-1].BigHuntScoreRewardGroupId
}
return 0
}
func (c *BigHuntCatalog) ResolveGradeIconId(bossId int32, score int64) int32 {
boss, ok := c.BossByBossId[bossId]
if !ok {
return 0
}
thresholds := c.GradeThresholds[boss.BigHuntBossGradeGroupId]
var iconId int32
for _, t := range thresholds {
if score >= t.NecessaryScore {
iconId = t.AssetGradeIconId
} else {
break
}
}
return iconId
}
func (c *BigHuntCatalog) CollectNewRewards(scoreRewardGroupId int32, oldMax, newMax int64) []RewardItem {
thresholds := c.ScoreRewardThresholds[scoreRewardGroupId]
var items []RewardItem
for _, t := range thresholds {
if t.NecessaryScore > oldMax && t.NecessaryScore <= newMax {
items = append(items, c.RewardItems[t.BigHuntRewardGroupId]...)
}
}
return items
}
func LoadBigHuntCatalog() *BigHuntCatalog {
bossQuestRows, err := utils.ReadJSON[bigHuntBossQuestRow]("EntityMBigHuntBossQuestTable.json")
if err != nil {
log.Fatalf("load big hunt boss quest table: %v", err)
}
bossQuestById := make(map[int32]BigHuntBossQuestRow, len(bossQuestRows))
for _, r := range bossQuestRows {
bossQuestById[r.BigHuntBossQuestId] = BigHuntBossQuestRow{
BigHuntBossQuestId: r.BigHuntBossQuestId,
BigHuntBossId: r.BigHuntBossId,
BigHuntQuestGroupId: r.BigHuntQuestGroupId,
BigHuntScoreRewardGroupScheduleId: r.BigHuntScoreRewardGroupScheduleId,
DailyChallengeCount: r.DailyChallengeCount,
}
}
questRows, err := utils.ReadJSON[bigHuntQuestRow]("EntityMBigHuntQuestTable.json")
if err != nil {
log.Fatalf("load big hunt quest table: %v", err)
}
questById := make(map[int32]BigHuntQuestRow, len(questRows))
for _, r := range questRows {
questById[r.BigHuntQuestId] = BigHuntQuestRow{
BigHuntQuestId: r.BigHuntQuestId,
QuestId: r.QuestId,
BigHuntQuestScoreCoefficientId: r.BigHuntQuestScoreCoefficientId,
}
}
coeffRows, err := utils.ReadJSON[bigHuntQuestScoreCoefficientRow]("EntityMBigHuntQuestScoreCoefficientTable.json")
if err != nil {
log.Fatalf("load big hunt quest score coefficient table: %v", err)
}
scoreCoefficients := make(map[int32]int32, len(coeffRows))
for _, r := range coeffRows {
scoreCoefficients[r.BigHuntQuestScoreCoefficientId] = r.ScoreDifficultBonusPermil
}
bossRows, err := utils.ReadJSON[bigHuntBossRow]("EntityMBigHuntBossTable.json")
if err != nil {
log.Fatalf("load big hunt boss table: %v", err)
}
bossByBossId := make(map[int32]BigHuntBossRow, len(bossRows))
for _, r := range bossRows {
bossByBossId[r.BigHuntBossId] = BigHuntBossRow{
BigHuntBossId: r.BigHuntBossId,
BigHuntBossGradeGroupId: r.BigHuntBossGradeGroupId,
AttributeType: r.AttributeType,
}
}
gradeRows, err := utils.ReadJSON[bigHuntBossGradeGroupRow]("EntityMBigHuntBossGradeGroupTable.json")
if err != nil {
log.Fatalf("load big hunt boss grade group table: %v", err)
}
gradeThresholds := make(map[int32][]GradeThreshold)
for _, r := range gradeRows {
gradeThresholds[r.BigHuntBossGradeGroupId] = append(gradeThresholds[r.BigHuntBossGradeGroupId], GradeThreshold{
NecessaryScore: r.NecessaryScore,
AssetGradeIconId: r.AssetGradeIconId,
})
}
for k := range gradeThresholds {
sort.Slice(gradeThresholds[k], func(i, j int) bool {
return gradeThresholds[k][i].NecessaryScore < gradeThresholds[k][j].NecessaryScore
})
}
scheduleRows, err := utils.ReadJSON[bigHuntScheduleRow]("EntityMBigHuntScheduleTable.json")
if err != nil {
log.Fatalf("load big hunt schedule table: %v", err)
}
nowMillis := time.Now().UnixMilli()
var activeScheduleId int32
var latestEndDatetime int64
for _, r := range scheduleRows {
if nowMillis >= r.ChallengeStartDatetime && nowMillis <= r.ChallengeEndDatetime {
activeScheduleId = r.BigHuntScheduleId
break
}
if r.ChallengeEndDatetime > latestEndDatetime {
latestEndDatetime = r.ChallengeEndDatetime
activeScheduleId = r.BigHuntScheduleId
}
}
rewardSchedRows, err := utils.ReadJSON[scoreRewardScheduleRow]("EntityMBigHuntScoreRewardGroupScheduleTable.json")
if err != nil {
log.Fatalf("load big hunt score reward group schedule table: %v", err)
}
scoreRewardSchedules := make(map[int32][]ScoreRewardScheduleEntry)
for _, r := range rewardSchedRows {
scoreRewardSchedules[r.BigHuntScoreRewardGroupScheduleId] = append(
scoreRewardSchedules[r.BigHuntScoreRewardGroupScheduleId],
ScoreRewardScheduleEntry{
BigHuntScoreRewardGroupId: r.BigHuntScoreRewardGroupId,
StartDatetime: r.StartDatetime,
},
)
}
for k := range scoreRewardSchedules {
sort.Slice(scoreRewardSchedules[k], func(i, j int) bool {
return scoreRewardSchedules[k][i].StartDatetime > scoreRewardSchedules[k][j].StartDatetime
})
}
rewardGroupRows, err := utils.ReadJSON[scoreRewardGroupRow]("EntityMBigHuntScoreRewardGroupTable.json")
if err != nil {
log.Fatalf("load big hunt score reward group table: %v", err)
}
scoreRewardThresholds := make(map[int32][]ScoreRewardThreshold)
for _, r := range rewardGroupRows {
scoreRewardThresholds[r.BigHuntScoreRewardGroupId] = append(
scoreRewardThresholds[r.BigHuntScoreRewardGroupId],
ScoreRewardThreshold{
NecessaryScore: r.NecessaryScore,
BigHuntRewardGroupId: r.BigHuntRewardGroupId,
},
)
}
for k := range scoreRewardThresholds {
sort.Slice(scoreRewardThresholds[k], func(i, j int) bool {
return scoreRewardThresholds[k][i].NecessaryScore < scoreRewardThresholds[k][j].NecessaryScore
})
}
rewardItemRows, err := utils.ReadJSON[rewardGroupRow]("EntityMBigHuntRewardGroupTable.json")
if err != nil {
log.Fatalf("load big hunt reward group table: %v", err)
}
rewardItems := make(map[int32][]RewardItem)
for _, r := range rewardItemRows {
rewardItems[r.BigHuntRewardGroupId] = append(rewardItems[r.BigHuntRewardGroupId], RewardItem{
PossessionType: r.PossessionType,
PossessionId: r.PossessionId,
Count: r.Count,
})
}
weeklySchedRows, err := utils.ReadJSON[weeklyRewardScheduleRow]("EntityMBigHuntWeeklyAttributeScoreRewardGroupScheduleTable.json")
if err != nil {
log.Fatalf("load big hunt weekly attribute score reward group schedule table: %v", err)
}
weeklyRewardSchedules := make(map[BigHuntWeeklyRewardKey][]ScoreRewardScheduleEntry)
for _, r := range weeklySchedRows {
key := BigHuntWeeklyRewardKey{
ScheduleId: r.BigHuntWeeklyAttributeScoreRewardGroupScheduleId,
AttributeType: r.AttributeType,
}
weeklyRewardSchedules[key] = append(weeklyRewardSchedules[key], ScoreRewardScheduleEntry{
BigHuntScoreRewardGroupId: r.BigHuntScoreRewardGroupId,
StartDatetime: r.StartDatetime,
})
}
for k := range weeklyRewardSchedules {
sort.Slice(weeklyRewardSchedules[k], func(i, j int) bool {
return weeklyRewardSchedules[k][i].StartDatetime > weeklyRewardSchedules[k][j].StartDatetime
})
}
log.Printf("big hunt catalog loaded: %d boss quests, %d quests, %d bosses, %d score coefficients, %d reward groups, schedule=%d",
len(bossQuestById), len(questById), len(bossByBossId), len(scoreCoefficients), len(rewardItems), activeScheduleId)
return &BigHuntCatalog{
BossQuestById: bossQuestById,
QuestById: questById,
ScoreCoefficients: scoreCoefficients,
BossByBossId: bossByBossId,
GradeThresholds: gradeThresholds,
ActiveScheduleId: activeScheduleId,
ScoreRewardSchedules: scoreRewardSchedules,
ScoreRewardThresholds: scoreRewardThresholds,
RewardItems: rewardItems,
WeeklyRewardSchedules: weeklyRewardSchedules,
}
}
@@ -0,0 +1,65 @@
package masterdata
import (
"log"
"lunar-tear/server/internal/utils"
)
type cageOrnament struct {
CageOrnamentId int32 `json:"CageOrnamentId"`
CageOrnamentRewardId int32 `json:"CageOrnamentRewardId"`
}
type cageOrnamentRewardRow struct {
CageOrnamentRewardId int32 `json:"CageOrnamentRewardId"`
PossessionType int32 `json:"PossessionType"`
PossessionId int32 `json:"PossessionId"`
Count int32 `json:"Count"`
}
type CageOrnamentReward struct {
PossessionType int32
PossessionId int32
Count int32
}
type CageOrnamentCatalog struct {
ornamentToRewardId map[int32]int32
rewards map[int32]CageOrnamentReward
}
func (c *CageOrnamentCatalog) LookupReward(cageOrnamentId int32) (CageOrnamentReward, bool) {
rewardId, ok := c.ornamentToRewardId[cageOrnamentId]
if !ok || rewardId == 0 {
return CageOrnamentReward{}, false
}
entry, ok := c.rewards[rewardId]
return entry, ok
}
func LoadCageOrnamentCatalog() *CageOrnamentCatalog {
ornaments, err := utils.ReadJSON[cageOrnament]("EntityMCageOrnamentTable.json")
if err != nil {
log.Fatalf("load cage ornament table: %v", err)
}
rewards, err := utils.ReadJSON[cageOrnamentRewardRow]("EntityMCageOrnamentRewardTable.json")
if err != nil {
log.Fatalf("load cage ornament reward table: %v", err)
}
cat := &CageOrnamentCatalog{
ornamentToRewardId: make(map[int32]int32, len(ornaments)),
rewards: make(map[int32]CageOrnamentReward, len(rewards)),
}
for _, o := range ornaments {
cat.ornamentToRewardId[o.CageOrnamentId] = o.CageOrnamentRewardId
}
for _, r := range rewards {
cat.rewards[r.CageOrnamentRewardId] = CageOrnamentReward{
PossessionType: r.PossessionType,
PossessionId: r.PossessionId,
Count: r.Count,
}
}
return cat
}
@@ -0,0 +1,74 @@
package masterdata
import (
"fmt"
"lunar-tear/server/internal/utils"
)
type CharacterRebirthRow struct {
CharacterId int32 `json:"CharacterId"`
CharacterRebirthStepGroupId int32 `json:"CharacterRebirthStepGroupId"`
}
type CharacterRebirthStepRow struct {
CharacterRebirthStepGroupId int32 `json:"CharacterRebirthStepGroupId"`
BeforeRebirthCount int32 `json:"BeforeRebirthCount"`
CostumeLevelLimitUp int32 `json:"CostumeLevelLimitUp"`
CharacterRebirthMaterialGroupId int32 `json:"CharacterRebirthMaterialGroupId"`
}
type CharacterRebirthMaterialRow struct {
CharacterRebirthMaterialGroupId int32 `json:"CharacterRebirthMaterialGroupId"`
MaterialId int32 `json:"MaterialId"`
Count int32 `json:"Count"`
}
type StepKey struct {
GroupId int32
BeforeRebirthCount int32
}
type CharacterRebirthCatalog struct {
StepGroupByCharacterId map[int32]int32
StepByGroupAndCount map[StepKey]CharacterRebirthStepRow
MaterialsByGroupId map[int32][]CharacterRebirthMaterialRow
}
func LoadCharacterRebirthCatalog() (*CharacterRebirthCatalog, error) {
rebirthRows, err := utils.ReadJSON[CharacterRebirthRow]("EntityMCharacterRebirthTable.json")
if err != nil {
return nil, fmt.Errorf("load character rebirth table: %w", err)
}
stepRows, err := utils.ReadJSON[CharacterRebirthStepRow]("EntityMCharacterRebirthStepGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load character rebirth step group table: %w", err)
}
materialRows, err := utils.ReadJSON[CharacterRebirthMaterialRow]("EntityMCharacterRebirthMaterialGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load character rebirth material group table: %w", err)
}
stepGroupByCharacterId := make(map[int32]int32, len(rebirthRows))
for _, r := range rebirthRows {
stepGroupByCharacterId[r.CharacterId] = r.CharacterRebirthStepGroupId
}
stepByGroupAndCount := make(map[StepKey]CharacterRebirthStepRow, len(stepRows))
for _, s := range stepRows {
stepByGroupAndCount[StepKey{GroupId: s.CharacterRebirthStepGroupId, BeforeRebirthCount: s.BeforeRebirthCount}] = s
}
materialsByGroupId := make(map[int32][]CharacterRebirthMaterialRow)
for _, m := range materialRows {
materialsByGroupId[m.CharacterRebirthMaterialGroupId] = append(materialsByGroupId[m.CharacterRebirthMaterialGroupId], m)
}
return &CharacterRebirthCatalog{
StepGroupByCharacterId: stepGroupByCharacterId,
StepByGroupAndCount: stepByGroupAndCount,
MaterialsByGroupId: materialsByGroupId,
}, nil
}
@@ -0,0 +1,183 @@
package masterdata
import (
"fmt"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils"
)
type CharacterBoardPanelRow struct {
CharacterBoardPanelId int32 `json:"CharacterBoardPanelId"`
CharacterBoardId int32 `json:"CharacterBoardId"`
CharacterBoardPanelUnlockConditionGroupId int32 `json:"CharacterBoardPanelUnlockConditionGroupId"`
CharacterBoardPanelReleasePossessionGroupId int32 `json:"CharacterBoardPanelReleasePossessionGroupId"`
CharacterBoardPanelReleaseRewardGroupId int32 `json:"CharacterBoardPanelReleaseRewardGroupId"`
CharacterBoardPanelReleaseEffectGroupId int32 `json:"CharacterBoardPanelReleaseEffectGroupId"`
SortOrder int32 `json:"SortOrder"`
ParentCharacterBoardPanelId int32 `json:"ParentCharacterBoardPanelId"`
PlaceIndex int32 `json:"PlaceIndex"`
}
type CharacterBoardReleasePossessionRow struct {
CharacterBoardPanelReleasePossessionGroupId int32 `json:"CharacterBoardPanelReleasePossessionGroupId"`
PossessionType int32 `json:"PossessionType"`
PossessionId int32 `json:"PossessionId"`
Count int32 `json:"Count"`
SortOrder int32 `json:"SortOrder"`
}
type CharacterBoardReleaseEffectRow struct {
CharacterBoardPanelReleaseEffectGroupId int32 `json:"CharacterBoardPanelReleaseEffectGroupId"`
SortOrder int32 `json:"SortOrder"`
CharacterBoardEffectType int32 `json:"CharacterBoardEffectType"`
CharacterBoardEffectId int32 `json:"CharacterBoardEffectId"`
EffectValue int32 `json:"EffectValue"`
}
type CharacterBoardRow struct {
CharacterBoardId int32 `json:"CharacterBoardId"`
CharacterBoardGroupId int32 `json:"CharacterBoardGroupId"`
CharacterBoardUnlockConditionGroupId int32 `json:"CharacterBoardUnlockConditionGroupId"`
ReleaseRank int32 `json:"ReleaseRank"`
}
type CharacterBoardStatusUpRow struct {
CharacterBoardStatusUpId int32 `json:"CharacterBoardStatusUpId"`
CharacterBoardStatusUpType int32 `json:"CharacterBoardStatusUpType"`
CharacterBoardEffectTargetGroupId int32 `json:"CharacterBoardEffectTargetGroupId"`
}
type CharacterBoardAbilityRow struct {
CharacterBoardAbilityId int32 `json:"CharacterBoardAbilityId"`
CharacterBoardEffectTargetGroupId int32 `json:"CharacterBoardEffectTargetGroupId"`
AbilityId int32 `json:"AbilityId"`
}
type CharacterBoardAbilityMaxLevelRow struct {
CharacterId int32 `json:"CharacterId"`
AbilityId int32 `json:"AbilityId"`
MaxLevel int32 `json:"MaxLevel"`
}
type CharacterBoardEffectTargetRow struct {
CharacterBoardEffectTargetGroupId int32 `json:"CharacterBoardEffectTargetGroupId"`
GroupIndex int32 `json:"GroupIndex"`
CharacterBoardEffectTargetType int32 `json:"CharacterBoardEffectTargetType"`
TargetValue int32 `json:"TargetValue"`
}
type CharacterBoardAssignmentRow struct {
CharacterId int32 `json:"CharacterId"`
CharacterBoardCategoryId int32 `json:"CharacterBoardCategoryId"`
SortOrder int32 `json:"SortOrder"`
CharacterBoardAssignmentType int32 `json:"CharacterBoardAssignmentType"`
}
type CharacterBoardGroupRow struct {
CharacterBoardGroupId int32 `json:"CharacterBoardGroupId"`
CharacterBoardCategoryId int32 `json:"CharacterBoardCategoryId"`
SortOrder int32 `json:"SortOrder"`
CharacterBoardGroupType int32 `json:"CharacterBoardGroupType"`
TextAssetId int32 `json:"TextAssetId"`
}
type CharacterBoardCatalog struct {
PanelById map[int32]CharacterBoardPanelRow
PanelsByBoardId map[int32][]CharacterBoardPanelRow
ReleaseCostsByGroupId map[int32][]CharacterBoardReleasePossessionRow
ReleaseEffectsByGroupId map[int32][]CharacterBoardReleaseEffectRow
StatusUpById map[int32]CharacterBoardStatusUpRow
AbilityById map[int32]CharacterBoardAbilityRow
AbilityMaxLevel map[store.CharacterBoardAbilityKey]int32
EffectTargetsByGroupId map[int32][]CharacterBoardEffectTargetRow
BoardById map[int32]CharacterBoardRow
}
func LoadCharacterBoardCatalog() (*CharacterBoardCatalog, error) {
panels, err := utils.ReadJSON[CharacterBoardPanelRow]("EntityMCharacterBoardPanelTable.json")
if err != nil {
return nil, fmt.Errorf("load character board panel table: %w", err)
}
costs, err := utils.ReadJSON[CharacterBoardReleasePossessionRow]("EntityMCharacterBoardPanelReleasePossessionGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load character board release possession table: %w", err)
}
effects, err := utils.ReadJSON[CharacterBoardReleaseEffectRow]("EntityMCharacterBoardPanelReleaseEffectGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load character board release effect table: %w", err)
}
boards, err := utils.ReadJSON[CharacterBoardRow]("EntityMCharacterBoardTable.json")
if err != nil {
return nil, fmt.Errorf("load character board table: %w", err)
}
statusUps, err := utils.ReadJSON[CharacterBoardStatusUpRow]("EntityMCharacterBoardStatusUpTable.json")
if err != nil {
return nil, fmt.Errorf("load character board status up table: %w", err)
}
abilities, err := utils.ReadJSON[CharacterBoardAbilityRow]("EntityMCharacterBoardAbilityTable.json")
if err != nil {
return nil, fmt.Errorf("load character board ability table: %w", err)
}
abilityMaxLevels, err := utils.ReadJSON[CharacterBoardAbilityMaxLevelRow]("EntityMCharacterBoardAbilityMaxLevelTable.json")
if err != nil {
return nil, fmt.Errorf("load character board ability max level table: %w", err)
}
targets, err := utils.ReadJSON[CharacterBoardEffectTargetRow]("EntityMCharacterBoardEffectTargetGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load character board effect target table: %w", err)
}
catalog := &CharacterBoardCatalog{
PanelById: make(map[int32]CharacterBoardPanelRow, len(panels)),
PanelsByBoardId: make(map[int32][]CharacterBoardPanelRow),
ReleaseCostsByGroupId: make(map[int32][]CharacterBoardReleasePossessionRow),
ReleaseEffectsByGroupId: make(map[int32][]CharacterBoardReleaseEffectRow),
StatusUpById: make(map[int32]CharacterBoardStatusUpRow, len(statusUps)),
AbilityById: make(map[int32]CharacterBoardAbilityRow, len(abilities)),
AbilityMaxLevel: make(map[store.CharacterBoardAbilityKey]int32, len(abilityMaxLevels)),
EffectTargetsByGroupId: make(map[int32][]CharacterBoardEffectTargetRow),
BoardById: make(map[int32]CharacterBoardRow, len(boards)),
}
for _, p := range panels {
catalog.PanelById[p.CharacterBoardPanelId] = p
catalog.PanelsByBoardId[p.CharacterBoardId] = append(catalog.PanelsByBoardId[p.CharacterBoardId], p)
}
for _, c := range costs {
catalog.ReleaseCostsByGroupId[c.CharacterBoardPanelReleasePossessionGroupId] = append(
catalog.ReleaseCostsByGroupId[c.CharacterBoardPanelReleasePossessionGroupId], c)
}
for _, e := range effects {
catalog.ReleaseEffectsByGroupId[e.CharacterBoardPanelReleaseEffectGroupId] = append(
catalog.ReleaseEffectsByGroupId[e.CharacterBoardPanelReleaseEffectGroupId], e)
}
for _, b := range boards {
catalog.BoardById[b.CharacterBoardId] = b
}
for _, s := range statusUps {
catalog.StatusUpById[s.CharacterBoardStatusUpId] = s
}
for _, a := range abilities {
catalog.AbilityById[a.CharacterBoardAbilityId] = a
}
for _, m := range abilityMaxLevels {
catalog.AbilityMaxLevel[store.CharacterBoardAbilityKey{
CharacterId: m.CharacterId,
AbilityId: m.AbilityId,
}] = m.MaxLevel
}
for _, t := range targets {
catalog.EffectTargetsByGroupId[t.CharacterBoardEffectTargetGroupId] = append(
catalog.EffectTargetsByGroupId[t.CharacterBoardEffectTargetGroupId], t)
}
return catalog, nil
}
@@ -0,0 +1,62 @@
package masterdata
import (
"log"
"sort"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils"
)
type characterViewerField struct {
CharacterViewerFieldId int32 `json:"CharacterViewerFieldId"`
ReleaseEvaluateConditionId int32 `json:"ReleaseEvaluateConditionId"`
}
type characterViewerFieldEntry struct {
FieldId int32
RequiredQuestId int32
}
type CharacterViewerCatalog struct {
fields []characterViewerFieldEntry
}
func (c *CharacterViewerCatalog) ReleasedFieldIds(user store.UserState) []int32 {
var released []int32
for _, f := range c.fields {
if f.RequiredQuestId == 0 {
released = append(released, f.FieldId)
continue
}
q, ok := user.Quests[f.RequiredQuestId]
if ok && q.QuestStateType == model.UserQuestStateTypeCleared {
released = append(released, f.FieldId)
}
}
return released
}
func LoadCharacterViewerCatalog(resolver *ConditionResolver) *CharacterViewerCatalog {
fields, err := utils.ReadJSON[characterViewerField]("EntityMCharacterViewerFieldTable.json")
if err != nil {
log.Fatalf("load character viewer field table: %v", err)
}
cat := &CharacterViewerCatalog{}
for _, f := range fields {
entry := characterViewerFieldEntry{FieldId: f.CharacterViewerFieldId}
if qid, ok := resolver.RequiredQuestId(f.ReleaseEvaluateConditionId); ok {
entry.RequiredQuestId = qid
}
cat.fields = append(cat.fields, entry)
}
sort.Slice(cat.fields, func(i, j int) bool {
return cat.fields[i].FieldId < cat.fields[j].FieldId
})
log.Printf("character viewer catalog loaded: %d fields", len(cat.fields))
return cat
}
+86
View File
@@ -0,0 +1,86 @@
package masterdata
import (
"fmt"
"lunar-tear/server/internal/utils"
)
type companionRow struct {
CompanionId int32 `json:"CompanionId"`
CompanionCategoryType int32 `json:"CompanionCategoryType"`
}
type companionCategoryRow struct {
CompanionCategoryType int32 `json:"CompanionCategoryType"`
EnhancementCostNumericalFunctionId int32 `json:"EnhancementCostNumericalFunctionId"`
}
type companionEnhancementMaterialRow struct {
CompanionCategoryType int32 `json:"CompanionCategoryType"`
Level int32 `json:"Level"`
MaterialId int32 `json:"MaterialId"`
Count int32 `json:"Count"`
}
type CompanionLevelKey struct {
CategoryType int32
Level int32
}
type CompanionMaterialCost struct {
MaterialId int32
Count int32
}
type CompanionCatalog struct {
CompanionById map[int32]companionRow
GoldCostByCategory map[int32]NumericalFunc
MaterialsByKey map[CompanionLevelKey]CompanionMaterialCost
}
func LoadCompanionCatalog() (*CompanionCatalog, error) {
companions, err := utils.ReadJSON[companionRow]("EntityMCompanionTable.json")
if err != nil {
return nil, fmt.Errorf("load companion table: %w", err)
}
categories, err := utils.ReadJSON[companionCategoryRow]("EntityMCompanionCategoryTable.json")
if err != nil {
return nil, fmt.Errorf("load companion category table: %w", err)
}
materials, err := utils.ReadJSON[companionEnhancementMaterialRow]("EntityMCompanionEnhancementMaterialTable.json")
if err != nil {
return nil, fmt.Errorf("load companion enhancement material table: %w", err)
}
funcResolver, err := LoadFunctionResolver()
if err != nil {
return nil, fmt.Errorf("load function resolver: %w", err)
}
companionById := make(map[int32]companionRow, len(companions))
for _, c := range companions {
companionById[c.CompanionId] = c
}
goldCostByCategory := make(map[int32]NumericalFunc, len(categories))
for _, cat := range categories {
if f, ok := funcResolver.Resolve(cat.EnhancementCostNumericalFunctionId); ok {
goldCostByCategory[cat.CompanionCategoryType] = f
}
}
materialsByKey := make(map[CompanionLevelKey]CompanionMaterialCost, len(materials))
for _, m := range materials {
key := CompanionLevelKey{CategoryType: m.CompanionCategoryType, Level: m.Level}
materialsByKey[key] = CompanionMaterialCost{MaterialId: m.MaterialId, Count: m.Count}
}
return &CompanionCatalog{
CompanionById: companionById,
GoldCostByCategory: goldCostByCategory,
MaterialsByKey: materialsByKey,
}, nil
}
+69
View File
@@ -0,0 +1,69 @@
package masterdata
import (
"fmt"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/utils"
)
type evaluateCondition struct {
EvaluateConditionId int32 `json:"EvaluateConditionId"`
EvaluateConditionFunctionType model.EvaluateConditionFunctionType `json:"EvaluateConditionFunctionType"`
EvaluateConditionEvaluateType model.EvaluateConditionEvaluateType `json:"EvaluateConditionEvaluateType"`
EvaluateConditionValueGroupId int32 `json:"EvaluateConditionValueGroupId"`
}
type evaluateConditionValueGroup struct {
EvaluateConditionValueGroupId int32 `json:"EvaluateConditionValueGroupId"`
GroupIndex int32 `json:"GroupIndex"`
Value int64 `json:"Value"`
}
const defaultGroupIndex = 1
type ConditionResolver struct {
requiredQuestByCondId map[int32]int32
}
func LoadConditionResolver() (*ConditionResolver, error) {
conditions, err := utils.ReadJSON[evaluateCondition]("EntityMEvaluateConditionTable.json")
if err != nil {
return nil, fmt.Errorf("load evaluate condition table: %w", err)
}
valueGroups, err := utils.ReadJSON[evaluateConditionValueGroup]("EntityMEvaluateConditionValueGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load evaluate condition value group table: %w", err)
}
condById := make(map[int32]evaluateCondition, len(conditions))
for _, c := range conditions {
condById[c.EvaluateConditionId] = c
}
type vgKey struct {
GroupId int32
GroupIndex int32
}
vgByKey := make(map[vgKey]int64, len(valueGroups))
for _, vg := range valueGroups {
vgByKey[vgKey{vg.EvaluateConditionValueGroupId, vg.GroupIndex}] = vg.Value
}
resolved := make(map[int32]int32)
for _, c := range conditions {
if c.EvaluateConditionFunctionType == model.EvaluateConditionFunctionTypeQuestClear &&
c.EvaluateConditionEvaluateType == model.EvaluateConditionEvaluateTypeIdContain {
if questId, ok := vgByKey[vgKey{c.EvaluateConditionValueGroupId, defaultGroupIndex}]; ok {
resolved[c.EvaluateConditionId] = int32(questId)
}
}
}
return &ConditionResolver{requiredQuestByCondId: resolved}, nil
}
func (r *ConditionResolver) RequiredQuestId(conditionId int32) (int32, bool) {
qid, ok := r.requiredQuestByCondId[conditionId]
return qid, ok
}
+89
View File
@@ -0,0 +1,89 @@
package masterdata
import (
"fmt"
"strconv"
"lunar-tear/server/internal/utils"
)
type configRow struct {
ConfigKey string `json:"ConfigKey"`
Value string `json:"Value"`
}
type GameConfig struct {
ConsumableItemIdForGold int32
ConsumableItemIdForMedal int32
ConsumableItemIdForRareMedal int32
ConsumableItemIdForArenaCoin int32
ConsumableItemIdForExploreTicket int32
ConsumableItemIdForMomPoint int32
ConsumableItemIdForPremiumGachaTicket int32
ConsumableItemIdForQuestSkipTicket int32
CharacterRebirthAvailableCount int32
CharacterRebirthConsumeGold int32
CostumeAwakenAvailableCount int32
CostumeLimitBreakAvailableCount int32
MaterialSameWeaponExpCoefficientPermil int32
UserStaminaRecoverySecond int32
RewardGachaDailyMaxCount int32
QuestSkipMaxCountAtOnce int32
WeaponLimitBreakAvailableCount int32
}
func LoadGameConfig() (*GameConfig, error) {
rows, err := utils.ReadJSON[configRow]("EntityMConfigTable.json")
if err != nil {
return nil, fmt.Errorf("load config table: %w", err)
}
kv := make(map[string]string, len(rows))
for _, r := range rows {
kv[r.ConfigKey] = r.Value
}
cfg := &GameConfig{}
cfg.ConsumableItemIdForGold = parseInt32(kv, "CONSUMABLE_ITEM_ID_FOR_GOLD")
cfg.ConsumableItemIdForMedal = parseInt32(kv, "CONSUMABLE_ITEM_ID_FOR_MEDAL")
cfg.ConsumableItemIdForRareMedal = parseInt32(kv, "CONSUMABLE_ITEM_ID_FOR_RARE_MEDAL")
cfg.ConsumableItemIdForArenaCoin = parseInt32(kv, "CONSUMABLE_ITEM_ID_FOR_ARENA_COIN")
cfg.ConsumableItemIdForExploreTicket = parseInt32(kv, "CONSUMABLE_ITEM_ID_FOR_EXPLORE_TICKET")
cfg.ConsumableItemIdForMomPoint = parseInt32(kv, "CONSUMABLE_ITEM_ID_FOR_MOM_POINT")
cfg.ConsumableItemIdForPremiumGachaTicket = parseInt32(kv, "CONSUMABLE_ITEM_ID_FOR_PREMIUM_GACHA_TICKET")
cfg.ConsumableItemIdForQuestSkipTicket = parseInt32(kv, "CONSUMABLE_ITEM_ID_FOR_QUEST_SKIP_TICKET")
cfg.CharacterRebirthAvailableCount = parseInt32(kv, "CHARACTER_REBIRTH_AVAILABLE_COUNT")
cfg.CharacterRebirthConsumeGold = parseInt32(kv, "CHARACTER_REBIRTH_CONSUME_GOLD")
cfg.CostumeAwakenAvailableCount = parseInt32(kv, "COSTUME_AWAKEN_AVAILABLE_COUNT")
cfg.CostumeLimitBreakAvailableCount = parseInt32(kv, "COSTUME_LIMIT_BREAK_AVAILABLE_COUNT")
cfg.MaterialSameWeaponExpCoefficientPermil = parseInt32(kv, "MATERIAL_SAME_WEAPON_EXP_COEFFICIENT_PERMIL")
cfg.UserStaminaRecoverySecond = parseInt32(kv, "USER_STAMINA_RECOVERY_SECOND")
cfg.RewardGachaDailyMaxCount = parseInt32(kv, "REWARD_GACHA_DAILY_MAX_COUNT")
cfg.QuestSkipMaxCountAtOnce = parseInt32(kv, "QUEST_SKIP_MAX_COUNT_AT_ONCE")
cfg.WeaponLimitBreakAvailableCount = parseInt32(kv, "WEAPON_LIMIT_BREAK_AVAILABLE_COUNT")
return cfg, nil
}
func parseInt32(kv map[string]string, key string) int32 {
s, ok := kv[key]
if !ok {
return 0
}
v, err := strconv.ParseInt(s, 10, 32)
if err != nil {
return 0
}
return int32(v)
}
+246
View File
@@ -0,0 +1,246 @@
package masterdata
import (
"fmt"
"sort"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/utils"
)
type CostumeMasterRow struct {
CostumeId int32 `json:"CostumeId"`
CharacterId int32 `json:"CharacterId"`
SkillfulWeaponType int32 `json:"SkillfulWeaponType"`
RarityType int32 `json:"RarityType"`
CostumeLimitBreakMaterialGroupId int32 `json:"CostumeLimitBreakMaterialGroupId"`
CostumeActiveSkillGroupId int32 `json:"CostumeActiveSkillGroupId"`
}
type costumeRarityRow struct {
RarityType int32 `json:"RarityType"`
CostumeLimitBreakMaterialRarityGroupId int32 `json:"CostumeLimitBreakMaterialRarityGroupId"`
RequiredExpForLevelUpNumericalParameterMapId int32 `json:"RequiredExpForLevelUpNumericalParameterMapId"`
EnhancementCostByMaterialNumericalFunctionId int32 `json:"EnhancementCostByMaterialNumericalFunctionId"`
LimitBreakCostNumericalFunctionId int32 `json:"LimitBreakCostNumericalFunctionId"`
MaxLevelNumericalFunctionId int32 `json:"MaxLevelNumericalFunctionId"`
ActiveSkillMaxLevelNumericalFunctionId int32 `json:"ActiveSkillMaxLevelNumericalFunctionId"`
ActiveSkillEnhancementCostNumericalFunctionId int32 `json:"ActiveSkillEnhancementCostNumericalFunctionId"`
}
type CostumeAwakenRow struct {
CostumeId int32 `json:"CostumeId"`
CostumeAwakenEffectGroupId int32 `json:"CostumeAwakenEffectGroupId"`
CostumeAwakenStepMaterialGroupId int32 `json:"CostumeAwakenStepMaterialGroupId"`
CostumeAwakenPriceGroupId int32 `json:"CostumeAwakenPriceGroupId"`
}
type costumeAwakenPriceRow struct {
CostumeAwakenPriceGroupId int32 `json:"CostumeAwakenPriceGroupId"`
AwakenStepLowerLimit int32 `json:"AwakenStepLowerLimit"`
Gold int32 `json:"Gold"`
}
type CostumeAwakenEffectRow struct {
CostumeAwakenEffectGroupId int32 `json:"CostumeAwakenEffectGroupId"`
AwakenStep int32 `json:"AwakenStep"`
CostumeAwakenEffectType int32 `json:"CostumeAwakenEffectType"`
CostumeAwakenEffectId int32 `json:"CostumeAwakenEffectId"`
}
type CostumeAwakenStatusUpRow struct {
CostumeAwakenStatusUpGroupId int32 `json:"CostumeAwakenStatusUpGroupId"`
SortOrder int32 `json:"SortOrder"`
StatusKindType int32 `json:"StatusKindType"`
StatusCalculationType int32 `json:"StatusCalculationType"`
EffectValue int32 `json:"EffectValue"`
}
type CostumeAwakenItemAcquireRow struct {
CostumeAwakenItemAcquireId int32 `json:"CostumeAwakenItemAcquireId"`
PossessionType int32 `json:"PossessionType"`
PossessionId int32 `json:"PossessionId"`
Count int32 `json:"Count"`
}
type CostumeActiveSkillGroupRow struct {
CostumeActiveSkillGroupId int32 `json:"CostumeActiveSkillGroupId"`
CostumeLimitBreakCountLowerLimit int32 `json:"CostumeLimitBreakCountLowerLimit"`
CostumeActiveSkillId int32 `json:"CostumeActiveSkillId"`
CostumeActiveSkillEnhancementMaterialId int32 `json:"CostumeActiveSkillEnhancementMaterialId"`
}
type CostumeActiveSkillEnhanceMaterialRow struct {
CostumeActiveSkillEnhancementMaterialId int32 `json:"CostumeActiveSkillEnhancementMaterialId"`
SkillLevel int32 `json:"SkillLevel"`
MaterialId int32 `json:"MaterialId"`
Count int32 `json:"Count"`
SortOrder int32 `json:"SortOrder"`
}
type CostumeCatalog struct {
Costumes map[int32]CostumeMasterRow
Materials map[int32]MaterialRow
ExpByRarity map[int32][]int32
EnhanceCostByRarity map[int32]NumericalFunc
MaxLevelByRarity map[int32]NumericalFunc
LimitBreakCostByRarity map[int32]NumericalFunc
AwakenByCostumeId map[int32]CostumeAwakenRow
AwakenPriceByGroup map[int32]int32
AwakenEffectsByGroupAndStep map[int32]map[int32]CostumeAwakenEffectRow
AwakenStatusUpByGroup map[int32][]CostumeAwakenStatusUpRow
AwakenItemAcquireById map[int32]CostumeAwakenItemAcquireRow
ActiveSkillGroupsByGroupId map[int32][]CostumeActiveSkillGroupRow // sorted by CostumeLimitBreakCountLowerLimit desc
ActiveSkillEnhanceMats map[[2]int32][]CostumeActiveSkillEnhanceMaterialRow // key: [enhancementMaterialId, skillLevel]
ActiveSkillMaxLevelByRarity map[int32]NumericalFunc
ActiveSkillCostByRarity map[int32]NumericalFunc
}
func LoadCostumeCatalog(matCatalog *MaterialCatalog) (*CostumeCatalog, error) {
costumes, err := utils.ReadJSON[CostumeMasterRow]("EntityMCostumeTable.json")
if err != nil {
return nil, fmt.Errorf("load costume table: %w", err)
}
rarities, err := utils.ReadJSON[costumeRarityRow]("EntityMCostumeRarityTable.json")
if err != nil {
return nil, fmt.Errorf("load costume rarity table: %w", err)
}
paramMapRows, err := LoadParameterMap()
if err != nil {
return nil, err
}
funcResolver, err := LoadFunctionResolver()
if err != nil {
return nil, fmt.Errorf("load function resolver: %w", err)
}
awakenRows, err := utils.ReadJSON[CostumeAwakenRow]("EntityMCostumeAwakenTable.json")
if err != nil {
return nil, fmt.Errorf("load costume awaken table: %w", err)
}
awakenPriceRows, err := utils.ReadJSON[costumeAwakenPriceRow]("EntityMCostumeAwakenPriceGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load costume awaken price table: %w", err)
}
awakenEffectRows, err := utils.ReadJSON[CostumeAwakenEffectRow]("EntityMCostumeAwakenEffectGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load costume awaken effect table: %w", err)
}
awakenStatusUpRows, err := utils.ReadJSON[CostumeAwakenStatusUpRow]("EntityMCostumeAwakenStatusUpGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load costume awaken status up table: %w", err)
}
awakenItemAcquireRows, err := utils.ReadJSON[CostumeAwakenItemAcquireRow]("EntityMCostumeAwakenItemAcquireTable.json")
if err != nil {
return nil, fmt.Errorf("load costume awaken item acquire table: %w", err)
}
activeSkillGroupRows, err := utils.ReadJSON[CostumeActiveSkillGroupRow]("EntityMCostumeActiveSkillGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load costume active skill group table: %w", err)
}
activeSkillMatRows, err := utils.ReadJSON[CostumeActiveSkillEnhanceMaterialRow]("EntityMCostumeActiveSkillEnhancementMaterialTable.json")
if err != nil {
return nil, fmt.Errorf("load costume active skill enhancement material table: %w", err)
}
catalog := &CostumeCatalog{
Costumes: make(map[int32]CostumeMasterRow, len(costumes)),
Materials: matCatalog.ByType[model.MaterialTypeCostumeEnhancement],
ExpByRarity: make(map[int32][]int32, len(rarities)),
EnhanceCostByRarity: make(map[int32]NumericalFunc, len(rarities)),
MaxLevelByRarity: make(map[int32]NumericalFunc, len(rarities)),
LimitBreakCostByRarity: make(map[int32]NumericalFunc, len(rarities)),
AwakenByCostumeId: make(map[int32]CostumeAwakenRow, len(awakenRows)),
AwakenPriceByGroup: make(map[int32]int32, len(awakenPriceRows)),
AwakenEffectsByGroupAndStep: make(map[int32]map[int32]CostumeAwakenEffectRow),
AwakenStatusUpByGroup: make(map[int32][]CostumeAwakenStatusUpRow),
AwakenItemAcquireById: make(map[int32]CostumeAwakenItemAcquireRow, len(awakenItemAcquireRows)),
ActiveSkillGroupsByGroupId: make(map[int32][]CostumeActiveSkillGroupRow),
ActiveSkillEnhanceMats: make(map[[2]int32][]CostumeActiveSkillEnhanceMaterialRow),
ActiveSkillMaxLevelByRarity: make(map[int32]NumericalFunc, len(rarities)),
ActiveSkillCostByRarity: make(map[int32]NumericalFunc, len(rarities)),
}
for _, row := range costumes {
catalog.Costumes[row.CostumeId] = row
}
for _, r := range rarities {
if _, ok := catalog.ExpByRarity[r.RarityType]; !ok {
catalog.ExpByRarity[r.RarityType] = BuildExpThresholds(paramMapRows, r.RequiredExpForLevelUpNumericalParameterMapId)
}
if _, ok := catalog.EnhanceCostByRarity[r.RarityType]; !ok {
if f, found := funcResolver.Resolve(r.EnhancementCostByMaterialNumericalFunctionId); found {
catalog.EnhanceCostByRarity[r.RarityType] = f
}
}
if _, ok := catalog.MaxLevelByRarity[r.RarityType]; !ok {
if f, found := funcResolver.Resolve(r.MaxLevelNumericalFunctionId); found {
catalog.MaxLevelByRarity[r.RarityType] = f
}
}
if _, ok := catalog.LimitBreakCostByRarity[r.RarityType]; !ok {
if f, found := funcResolver.Resolve(r.LimitBreakCostNumericalFunctionId); found {
catalog.LimitBreakCostByRarity[r.RarityType] = f
}
}
if _, ok := catalog.ActiveSkillMaxLevelByRarity[r.RarityType]; !ok {
if f, found := funcResolver.Resolve(r.ActiveSkillMaxLevelNumericalFunctionId); found {
catalog.ActiveSkillMaxLevelByRarity[r.RarityType] = f
}
}
if _, ok := catalog.ActiveSkillCostByRarity[r.RarityType]; !ok {
if f, found := funcResolver.Resolve(r.ActiveSkillEnhancementCostNumericalFunctionId); found {
catalog.ActiveSkillCostByRarity[r.RarityType] = f
}
}
}
for _, row := range awakenRows {
catalog.AwakenByCostumeId[row.CostumeId] = row
}
for _, row := range awakenPriceRows {
catalog.AwakenPriceByGroup[row.CostumeAwakenPriceGroupId] = row.Gold
}
for _, row := range awakenEffectRows {
m, ok := catalog.AwakenEffectsByGroupAndStep[row.CostumeAwakenEffectGroupId]
if !ok {
m = make(map[int32]CostumeAwakenEffectRow)
catalog.AwakenEffectsByGroupAndStep[row.CostumeAwakenEffectGroupId] = m
}
m[row.AwakenStep] = row
}
for _, row := range awakenStatusUpRows {
catalog.AwakenStatusUpByGroup[row.CostumeAwakenStatusUpGroupId] = append(
catalog.AwakenStatusUpByGroup[row.CostumeAwakenStatusUpGroupId], row)
}
for _, row := range awakenItemAcquireRows {
catalog.AwakenItemAcquireById[row.CostumeAwakenItemAcquireId] = row
}
for _, row := range activeSkillGroupRows {
gid := row.CostumeActiveSkillGroupId
catalog.ActiveSkillGroupsByGroupId[gid] = append(catalog.ActiveSkillGroupsByGroupId[gid], row)
}
for gid, rows := range catalog.ActiveSkillGroupsByGroupId {
sort.Slice(rows, func(i, j int) bool {
return rows[i].CostumeLimitBreakCountLowerLimit > rows[j].CostumeLimitBreakCountLowerLimit
})
catalog.ActiveSkillGroupsByGroupId[gid] = rows
}
for _, row := range activeSkillMatRows {
key := [2]int32{row.CostumeActiveSkillEnhancementMaterialId, row.SkillLevel}
catalog.ActiveSkillEnhanceMats[key] = append(catalog.ActiveSkillEnhanceMats[key], row)
}
return catalog, nil
}
@@ -0,0 +1,81 @@
package masterdata
import (
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/utils"
)
type costumeDupRow struct {
CostumeId int32 `json:"CostumeId"`
PossessionType int32 `json:"PossessionType"`
PossessionId int32 `json:"PossessionId"`
Count int32 `json:"Count"`
}
func LoadDupExchange() (map[int32][]model.DupExchangeEntry, error) {
result := make(map[int32][]model.DupExchangeEntry)
costumeRows, err := utils.ReadJSON[costumeDupRow]("EntityMCostumeDuplicationExchangePossessionGroupTable.json")
if err != nil {
return nil, err
}
for _, r := range costumeRows {
result[r.CostumeId] = append(result[r.CostumeId], model.DupExchangeEntry{
PossessionType: r.PossessionType,
PossessionId: r.PossessionId,
Count: r.Count,
})
}
return result, nil
}
type lbMaterialRow struct {
CostumeLimitBreakMaterialGroupId int32 `json:"CostumeLimitBreakMaterialGroupId"`
MaterialId int32 `json:"MaterialId"`
}
type costumeLBRef struct {
CostumeId int32 `json:"CostumeId"`
CostumeLimitBreakMaterialGroupId int32 `json:"CostumeLimitBreakMaterialGroupId"`
}
const dupExchangeFallbackCount int32 = 10
func EnrichDupExchange(dupMap map[int32][]model.DupExchangeEntry, pool *GachaCatalog) (int, error) {
lbRows, err := utils.ReadJSON[lbMaterialRow]("EntityMCostumeLimitBreakMaterialGroupTable.json")
if err != nil {
return 0, err
}
groupToMaterial := make(map[int32]int32, len(lbRows))
for _, r := range lbRows {
groupToMaterial[r.CostumeLimitBreakMaterialGroupId] = r.MaterialId
}
costumeRows, err := utils.ReadJSON[costumeLBRef]("EntityMCostumeTable.json")
if err != nil {
return 0, err
}
costumeLBGroup := make(map[int32]int32, len(costumeRows))
for _, r := range costumeRows {
costumeLBGroup[r.CostumeId] = r.CostumeLimitBreakMaterialGroupId
}
added := 0
for costumeId := range pool.CostumeById {
if _, exists := dupMap[costumeId]; exists {
continue
}
matId := groupToMaterial[costumeLBGroup[costumeId]]
if matId == 0 {
continue
}
dupMap[costumeId] = []model.DupExchangeEntry{{
PossessionType: int32(model.PossessionTypeMaterial),
PossessionId: matId,
Count: dupExchangeFallbackCount,
}}
added++
}
return added, nil
}
+90
View File
@@ -0,0 +1,90 @@
package masterdata
import (
"fmt"
"sort"
"lunar-tear/server/internal/utils"
)
type ExploreRow struct {
ExploreId int32 `json:"ExploreId"`
ConsumeItemCount int32 `json:"ConsumeItemCount"`
RewardLotteryCount int32 `json:"RewardLotteryCount"`
}
type ExploreGradeScoreRow struct {
ExploreId int32 `json:"ExploreId"`
NecessaryScore int32 `json:"NecessaryScore"`
ExploreGradeId int32 `json:"ExploreGradeId"`
}
type ExploreGradeAssetRow struct {
ExploreGradeId int32 `json:"ExploreGradeId"`
AssetGradeIconId int32 `json:"AssetGradeIconId"`
}
type ExploreCatalog struct {
Explores map[int32]ExploreRow
GradeScores map[int32][]ExploreGradeScoreRow // keyed by ExploreId, sorted desc by NecessaryScore
GradeAssets map[int32]int32 // gradeId -> assetGradeIconId
}
func LoadExploreCatalog() (*ExploreCatalog, error) {
explores, err := utils.ReadJSON[ExploreRow]("EntityMExploreTable.json")
if err != nil {
return nil, fmt.Errorf("load explore table: %w", err)
}
gradeScores, err := utils.ReadJSON[ExploreGradeScoreRow]("EntityMExploreGradeScoreTable.json")
if err != nil {
return nil, fmt.Errorf("load explore grade score table: %w", err)
}
gradeAssets, err := utils.ReadJSON[ExploreGradeAssetRow]("EntityMExploreGradeAssetTable.json")
if err != nil {
return nil, fmt.Errorf("load explore grade asset table: %w", err)
}
catalog := &ExploreCatalog{
Explores: make(map[int32]ExploreRow, len(explores)),
GradeScores: make(map[int32][]ExploreGradeScoreRow),
GradeAssets: make(map[int32]int32, len(gradeAssets)),
}
for _, e := range explores {
catalog.Explores[e.ExploreId] = e
}
for _, gs := range gradeScores {
catalog.GradeScores[gs.ExploreId] = append(catalog.GradeScores[gs.ExploreId], gs)
}
for eid := range catalog.GradeScores {
rows := catalog.GradeScores[eid]
sort.Slice(rows, func(i, j int) bool {
return rows[i].NecessaryScore > rows[j].NecessaryScore
})
catalog.GradeScores[eid] = rows
}
for _, ga := range gradeAssets {
catalog.GradeAssets[ga.ExploreGradeId] = ga.AssetGradeIconId
}
return catalog, nil
}
// GradeForScore returns the AssetGradeIconId for the given explore and score.
// Returns 0 if no matching grade is found.
func (c *ExploreCatalog) GradeForScore(exploreId, score int32) int32 {
rows, ok := c.GradeScores[exploreId]
if !ok {
return 0
}
for _, r := range rows {
if score >= r.NecessaryScore {
return c.GradeAssets[r.ExploreGradeId]
}
}
return 0
}
+364
View File
@@ -0,0 +1,364 @@
package masterdata
import (
"fmt"
"sort"
"strings"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils"
)
type gachaMedalRow struct {
GachaMedalId int32 `json:"GachaMedalId"`
ShopTransitionGachaId int32 `json:"ShopTransitionGachaId"`
ConsumableItemId int32 `json:"ConsumableItemId"`
AutoConvertDatetime int64 `json:"AutoConvertDatetime"`
ConversionRate int32 `json:"ConversionRate"`
}
type momBannerRow struct {
MomBannerId int32 `json:"MomBannerId"`
SortOrderDesc int32 `json:"SortOrderDesc"`
DestinationDomainType int32 `json:"DestinationDomainType"`
DestinationDomainId int32 `json:"DestinationDomainId"`
BannerAssetName string `json:"BannerAssetName"`
StartDatetime int64 `json:"StartDatetime"`
EndDatetime int64 `json:"EndDatetime"`
}
type GachaMedalInfo struct {
GachaMedalId int32
ConsumableItemId int32
AutoConvertDatetime int64
ConversionRate int32
}
const chapterGachaIdBase int32 = 200000
func LoadGachaCatalog() ([]store.GachaCatalogEntry, map[int32]GachaMedalInfo, error) {
medals, err := utils.ReadJSON[gachaMedalRow]("EntityMGachaMedalTable.json")
if err != nil {
return nil, nil, fmt.Errorf("load gacha medal table: %w", err)
}
banners, err := utils.ReadJSON[momBannerRow]("EntityMMomBannerTable.json")
if err != nil {
return nil, nil, fmt.Errorf("load mom banner table: %w", err)
}
gachaToMedal := make(map[int32]gachaMedalRow)
medalInfoByGacha := make(map[int32]GachaMedalInfo)
for _, m := range medals {
gachaToMedal[m.ShopTransitionGachaId] = m
medalInfoByGacha[m.ShopTransitionGachaId] = GachaMedalInfo{
GachaMedalId: m.GachaMedalId,
ConsumableItemId: m.ConsumableItemId,
AutoConvertDatetime: m.AutoConvertDatetime,
ConversionRate: m.ConversionRate,
}
}
stepupSteps := make(map[int32][]momBannerRow)
var entries []store.GachaCatalogEntry
for _, b := range banners {
if b.DestinationDomainType != model.MomBannerDomainGacha {
continue
}
gachaId := b.DestinationDomainId
if strings.HasPrefix(b.BannerAssetName, model.BannerPrefixStepUp) {
if _, hasMedal := gachaToMedal[gachaId]; !hasMedal {
continue
}
groupId := gachaId / model.StepUpGroupDivisor
stepupSteps[groupId] = append(stepupSteps[groupId], b)
continue
}
labelType := model.GachaLabelPremium
modeType := model.GachaModeBasic
decoration := model.GachaDecorationNormal
isChapter := strings.HasPrefix(b.BannerAssetName, model.BannerPrefixCommon)
if strings.HasPrefix(b.BannerAssetName, model.BannerPrefixLimited) {
decoration = model.GachaDecorationFestival
}
if isChapter {
labelType = model.GachaLabelChapter
modeType = model.GachaModeBox
}
medal, hasMedal := gachaToMedal[gachaId]
if !hasMedal && !isChapter {
continue
}
var medalId int32
var medalConsumableId int32
var ceilingCount int32
if hasMedal {
medalId = medal.GachaMedalId
medalConsumableId = medal.ConsumableItemId
ceilingCount = model.PityCeilingCount
}
var pricePhases []store.GachaPricePhaseEntry
if isChapter {
pricePhases = buildChapterPricePhases(gachaId)
} else {
pricePhases = buildPremiumBasicPricePhases(gachaId)
}
relMainQuest := int32(0)
if isChapter {
relMainQuest = gachaId - chapterGachaIdBase
}
var descriptionTextId int32
if isChapter {
descriptionTextId = gachaId
}
entries = append(entries, store.GachaCatalogEntry{
GachaId: gachaId,
GachaLabelType: labelType,
GachaModeType: modeType,
GachaAutoResetType: model.GachaAutoResetNone,
IsUserGachaUnlock: true,
StartDatetime: b.StartDatetime,
EndDatetime: b.EndDatetime,
RelatedMainQuestChapterId: relMainQuest,
GachaMedalId: medalId,
MedalConsumableItemId: medalConsumableId,
GachaDecorationType: decoration,
SortOrder: b.SortOrderDesc,
BannerAssetName: b.BannerAssetName,
GroupId: gachaId,
CeilingCount: ceilingCount,
PricePhases: pricePhases,
DescriptionTextId: descriptionTextId,
})
}
for groupId, steps := range stepupSteps {
first := steps[0]
gachaId := groupId
medal := gachaToMedal[first.DestinationDomainId]
medalId := medal.GachaMedalId
medalConsumableId := medal.ConsumableItemId
pricePhases := buildStepUpPricePhases(gachaId, len(steps))
var maxStep int32
for _, p := range pricePhases {
if p.StepNumber > maxStep {
maxStep = p.StepNumber
}
}
entries = append(entries, store.GachaCatalogEntry{
GachaId: gachaId,
GachaLabelType: model.GachaLabelPremium,
GachaModeType: model.GachaModeStepup,
GachaAutoResetType: model.GachaAutoResetNone,
IsUserGachaUnlock: true,
StartDatetime: first.StartDatetime,
EndDatetime: first.EndDatetime,
GachaMedalId: medalId,
MedalConsumableItemId: medalConsumableId,
GachaDecorationType: model.GachaDecorationFestival,
SortOrder: first.SortOrderDesc,
BannerAssetName: first.BannerAssetName,
GroupId: groupId,
CeilingCount: model.PityCeilingCount,
PricePhases: pricePhases,
MaxStepNumber: maxStep,
})
}
return entries, medalInfoByGacha, nil
}
const chapterPromoMaxItems = 4
const maxSlideFeatured = 13
func EnrichCatalogPromotions(entries []store.GachaCatalogEntry, pool *GachaCatalog) {
for i := range entries {
if entries[i].GachaLabelType == model.GachaLabelChapter {
entries[i].PromotionItems = buildChapterPromotionItems(pool.Materials)
continue
}
featured := pool.FeaturedByGacha[entries[i].GachaId]
maxRarity := int32(0)
for _, c := range featured.Costumes {
if c.RarityType > maxRarity {
maxRarity = c.RarityType
}
}
for _, w := range featured.Weapons {
if w.RarityType > maxRarity {
maxRarity = w.RarityType
}
}
var topCostumes []GachaPoolItem
for _, c := range featured.Costumes {
if c.RarityType == maxRarity {
topCostumes = append(topCostumes, c)
}
}
var topWeapons []GachaPoolItem
for _, w := range featured.Weapons {
if w.RarityType == maxRarity {
topWeapons = append(topWeapons, w)
}
}
if len(topCostumes)+len(topWeapons) > maxSlideFeatured {
topCostumes = topCostumes[:min(3, len(topCostumes))]
topWeapons = topWeapons[:min(2, len(topWeapons))]
}
var items []store.GachaPromotionItem
if entries[i].GachaModeType == model.GachaModeStepup && len(topCostumes) > 0 {
items = append(items, toPromoItemWithBonus(topCostumes[0], pool))
wid := pool.CostumeWeaponMap[topCostumes[0].PossessionId]
items = append(items, toPromoItem(pool.WeaponById[wid]))
} else {
for _, c := range topCostumes {
items = append(items, toPromoItemWithBonus(c, pool))
}
for _, w := range topWeapons {
items = append(items, toPromoItemWithBonus(w, pool))
}
}
entries[i].PromotionItems = items
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].SortOrder != entries[j].SortOrder {
return entries[i].SortOrder < entries[j].SortOrder
}
return entries[i].GachaId < entries[j].GachaId
})
}
func toPromoItem(item GachaPoolItem) store.GachaPromotionItem {
return store.GachaPromotionItem{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
IsTarget: true,
}
}
func toPromoItemWithBonus(item GachaPoolItem, pool *GachaCatalog) store.GachaPromotionItem {
pi := store.GachaPromotionItem{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
IsTarget: true,
}
if item.PossessionType == int32(model.PossessionTypeCostume) {
pi.BonusPossessionType = int32(model.PossessionTypeWeapon)
pi.BonusPossessionId = pool.CostumeWeaponMap[item.PossessionId]
}
return pi
}
func buildChapterPromotionItems(materials []GachaPoolItem) []store.GachaPromotionItem {
limit := min(chapterPromoMaxItems, len(materials))
items := make([]store.GachaPromotionItem, 0, limit)
for _, m := range materials[:limit] {
items = append(items, toPromoItem(m))
}
return items
}
func buildPremiumBasicPricePhases(gachaId int32) []store.GachaPricePhaseEntry {
return []store.GachaPricePhaseEntry{
{
PhaseId: gachaId*model.PhaseIdMultiplier + 1,
PriceType: model.PriceTypeGem,
Price: model.PremiumSinglePullPrice,
RegularPrice: model.PremiumSinglePullPrice,
DrawCount: 1,
},
{
PhaseId: gachaId*model.PhaseIdMultiplier + 2,
PriceType: model.PriceTypeGem,
Price: model.PremiumMultiPullPrice,
RegularPrice: model.PremiumMultiPullPrice,
DrawCount: model.PremiumMultiPullCount,
FixedRarityMin: model.RaritySRare,
FixedCount: 1,
},
{
PhaseId: gachaId*model.PhaseIdMultiplier + 3,
PriceType: model.PriceTypeConsumableItem,
PriceId: model.ConsumableIdPremiumTicket,
Price: 1,
RegularPrice: 1,
DrawCount: 1,
},
}
}
func buildStepUpPricePhases(gachaId int32, totalSteps int) []store.GachaPricePhaseEntry {
stepCosts := []int32{model.StepUpStep1Cost, model.StepUpFreeCost, model.StepUpStep3Cost, model.StepUpFreeCost, model.StepUpStep5Cost}
stepCosts = stepCosts[:min(totalSteps, len(stepCosts))]
var phases []store.GachaPricePhaseEntry
for i, cost := range stepCosts {
step := int32(i + 1)
priceType := model.PriceTypePaidGem
if cost == 0 {
priceType = model.PriceTypeGem
}
fixedRarityMin := int32(0)
fixedCount := int32(0)
if step == int32(len(stepCosts)) {
fixedRarityMin = model.RaritySSRare
fixedCount = 1
}
phases = append(phases, store.GachaPricePhaseEntry{
PhaseId: gachaId*model.PhaseIdMultiplier + step,
PriceType: priceType,
Price: cost,
RegularPrice: model.PremiumMultiPullPrice,
DrawCount: model.PremiumMultiPullCount,
FixedRarityMin: fixedRarityMin,
FixedCount: fixedCount,
LimitExecCount: 1,
StepNumber: step,
})
}
return phases
}
func buildChapterPricePhases(gachaId int32) []store.GachaPricePhaseEntry {
return []store.GachaPricePhaseEntry{
{
PhaseId: gachaId*model.PhaseIdMultiplier + 1,
PriceType: model.PriceTypeConsumableItem,
PriceId: model.ConsumableIdChapterTicket,
Price: 1,
RegularPrice: 1,
DrawCount: 1,
},
{
PhaseId: gachaId*model.PhaseIdMultiplier + 2,
PriceType: model.PriceTypeConsumableItem,
PriceId: model.ConsumableIdChapterTicket,
Price: 10,
RegularPrice: 10,
DrawCount: model.PremiumMultiPullCount,
},
}
}
+432
View File
@@ -0,0 +1,432 @@
package masterdata
import (
"fmt"
"log"
"slices"
"sort"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils"
)
type GachaPoolItem struct {
PossessionType int32
PossessionId int32
RarityType model.RarityType
CharacterId int32
}
type FeaturedSet struct {
Costumes []GachaPoolItem
Weapons []GachaPoolItem
}
type BannerPool struct {
CostumesByRarity map[int32][]GachaPoolItem
WeaponsByRarity map[int32][]GachaPoolItem
Featured []GachaPoolItem
}
type ShopFeaturedEntry struct {
CostumeId int32
WeaponId int32
}
type GachaCatalog struct {
CostumesByRarity map[int32][]GachaPoolItem
WeaponsByRarity map[int32][]GachaPoolItem
Materials []GachaPoolItem
CostumeById map[int32]GachaPoolItem
WeaponById map[int32]GachaPoolItem
CostumeWeaponMap map[int32]int32 // costumeId -> paired weaponId
FeaturedByGacha map[int32]FeaturedSet
BannerPools map[int32]*BannerPool
ShopFeaturedByMedal map[int32][]ShopFeaturedEntry // consumableId -> paired entries
}
type costumePoolRow struct {
CostumeId int32 `json:"CostumeId"`
CharacterId int32 `json:"CharacterId"`
SkillfulWeaponType int32 `json:"SkillfulWeaponType"`
RarityType int32 `json:"RarityType"`
}
type weaponPoolRow struct {
WeaponId int32 `json:"WeaponId"`
WeaponType int32 `json:"WeaponType"`
RarityType int32 `json:"RarityType"`
IsRestrictDiscard bool `json:"IsRestrictDiscard"`
}
type catalogCostumeRow struct {
CostumeId int32 `json:"CostumeId"`
CatalogTermId int32 `json:"CatalogTermId"`
}
type catalogWeaponRow struct {
WeaponId int32 `json:"WeaponId"`
CatalogTermId int32 `json:"CatalogTermId"`
}
type materialPoolRow struct {
MaterialId int32 `json:"MaterialId"`
MaterialType int32 `json:"MaterialType"`
RarityType int32 `json:"RarityType"`
}
func LoadGachaPool() (*GachaCatalog, error) {
costumes, err := utils.ReadJSON[costumePoolRow]("EntityMCostumeTable.json")
if err != nil {
return nil, fmt.Errorf("load costume table: %w", err)
}
weapons, err := utils.ReadJSON[weaponPoolRow]("EntityMWeaponTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon table: %w", err)
}
catalogCostumes, err := utils.ReadJSON[catalogCostumeRow]("EntityMCatalogCostumeTable.json")
if err != nil {
return nil, fmt.Errorf("load catalog costume table: %w", err)
}
catalogWeapons, err := utils.ReadJSON[catalogWeaponRow]("EntityMCatalogWeaponTable.json")
if err != nil {
return nil, fmt.Errorf("load catalog weapon table: %w", err)
}
materials, err := utils.ReadJSON[materialPoolRow]("EntityMMaterialTable.json")
if err != nil {
return nil, fmt.Errorf("load material table: %w", err)
}
evoGroupRows, err := utils.ReadJSON[WeaponEvolutionGroupRow]("EntityMWeaponEvolutionGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon evolution group table: %w", err)
}
evolvedWeapons := buildEvolvedWeaponSet(evoGroupRows)
catalogCostumeSet := make(map[int32]bool, len(catalogCostumes))
costumeTermId := make(map[int32]int32, len(catalogCostumes))
for _, c := range catalogCostumes {
catalogCostumeSet[c.CostumeId] = true
costumeTermId[c.CostumeId] = c.CatalogTermId
}
catalogWeaponSet := make(map[int32]bool, len(catalogWeapons))
for _, w := range catalogWeapons {
catalogWeaponSet[w.WeaponId] = true
}
costumeWeaponType := make(map[int32]int32, len(costumes))
for _, c := range costumes {
costumeWeaponType[c.CostumeId] = c.SkillfulWeaponType
}
weaponTypeById := make(map[int32]int32, len(weapons))
weaponRarityById := make(map[int32]int32, len(weapons))
restrictedWeapons := make(map[int32]bool)
for _, w := range weapons {
weaponTypeById[w.WeaponId] = w.WeaponType
weaponRarityById[w.WeaponId] = w.RarityType
if w.IsRestrictDiscard {
restrictedWeapons[w.WeaponId] = true
}
}
pool := &GachaCatalog{
CostumesByRarity: make(map[int32][]GachaPoolItem),
WeaponsByRarity: make(map[int32][]GachaPoolItem),
CostumeById: make(map[int32]GachaPoolItem),
WeaponById: make(map[int32]GachaPoolItem),
CostumeWeaponMap: make(map[int32]int32),
FeaturedByGacha: make(map[int32]FeaturedSet),
}
for _, c := range costumes {
if !catalogCostumeSet[c.CostumeId] {
continue
}
if c.RarityType < model.RaritySRare {
continue
}
item := GachaPoolItem{
PossessionType: int32(model.PossessionTypeCostume),
PossessionId: c.CostumeId,
RarityType: c.RarityType,
CharacterId: c.CharacterId,
}
pool.CostumesByRarity[c.RarityType] = append(pool.CostumesByRarity[c.RarityType], item)
pool.CostumeById[c.CostumeId] = item
}
restrictedCount := 0
for _, w := range weapons {
if !catalogWeaponSet[w.WeaponId] {
continue
}
if evolvedWeapons[w.WeaponId] {
continue
}
item := GachaPoolItem{
PossessionType: int32(model.PossessionTypeWeapon),
PossessionId: w.WeaponId,
RarityType: w.RarityType,
}
pool.WeaponById[w.WeaponId] = item
if w.IsRestrictDiscard {
restrictedCount++
continue
}
pool.WeaponsByRarity[w.RarityType] = append(pool.WeaponsByRarity[w.RarityType], item)
}
log.Printf("[GachaPool] excluded %d evolved weapons, %d restricted weapons from pool", len(evolvedWeapons), restrictedCount)
type weaponKey struct {
TermId int32
WeaponType int32
Rarity int32
}
weaponsByKey := make(map[weaponKey][]int32)
for _, cw := range catalogWeapons {
if evolvedWeapons[cw.WeaponId] || restrictedWeapons[cw.WeaponId] {
continue
}
wt := weaponTypeById[cw.WeaponId]
r := weaponRarityById[cw.WeaponId]
if wt == 0 || r < model.RaritySRare {
continue
}
k := weaponKey{TermId: cw.CatalogTermId, WeaponType: wt, Rarity: r}
weaponsByKey[k] = append(weaponsByKey[k], cw.WeaponId)
}
for k, ids := range weaponsByKey {
slices.Sort(ids)
weaponsByKey[k] = ids
}
exact, pattern, bestGuess := 0, 0, 0
for costumeId, item := range pool.CostumeById {
tid := costumeTermId[costumeId]
wt := costumeWeaponType[costumeId]
k := weaponKey{TermId: tid, WeaponType: wt, Rarity: item.RarityType}
candidates := weaponsByKey[k]
if len(candidates) == 0 {
continue
}
if len(candidates) == 1 {
pool.CostumeWeaponMap[costumeId] = candidates[0]
exact++
continue
}
idPattern := costumeId*10 + 1
found := false
for _, wid := range candidates {
if wid == idPattern {
pool.CostumeWeaponMap[costumeId] = wid
pattern++
found = true
break
}
}
if !found {
pool.CostumeWeaponMap[costumeId] = candidates[0]
bestGuess++
}
}
log.Printf("[GachaPool] costume-weapon pairing: %d exact, %d id-pattern, %d best-guess, %d total",
exact, pattern, bestGuess, len(pool.CostumeWeaponMap))
for _, m := range materials {
pool.Materials = append(pool.Materials, GachaPoolItem{
PossessionType: int32(model.PossessionTypeMaterial),
PossessionId: m.MaterialId,
RarityType: m.RarityType,
})
}
return pool, nil
}
func (pool *GachaCatalog) BuildShopFeatured(shop *ShopCatalog) {
pool.ShopFeaturedByMedal = make(map[int32][]ShopFeaturedEntry)
shopPairs := 0
for _, cells := range shop.ExchangeShopCells {
consumableId := shop.Items[cells[0].ShopItemId].PriceId
var entries []ShopFeaturedEntry
for _, cell := range cells {
contents := shop.Contents[cell.ShopItemId]
var costumeId, weaponId int32
for _, c := range contents {
switch c.PossessionType {
case int32(model.PossessionTypeCostume):
costumeId = c.PossessionId
case int32(model.PossessionTypeWeapon):
weaponId = c.PossessionId
}
}
if costumeId == 0 && weaponId == 0 {
continue
}
entries = append(entries, ShopFeaturedEntry{CostumeId: costumeId, WeaponId: weaponId})
if costumeId != 0 && weaponId != 0 {
pool.CostumeWeaponMap[costumeId] = weaponId
shopPairs++
}
}
if len(entries) > 0 {
pool.ShopFeaturedByMedal[consumableId] = entries
}
}
log.Printf("[GachaPool] shop featured: %d consumables, %d costume-weapon pairs overridden", len(pool.ShopFeaturedByMedal), shopPairs)
}
func (pool *GachaCatalog) PruneUnpairedCostumes() {
pruned := 0
for costumeId := range pool.CostumeById {
if _, ok := pool.CostumeWeaponMap[costumeId]; !ok {
delete(pool.CostumeById, costumeId)
pruned++
}
}
for rarity, items := range pool.CostumesByRarity {
filtered := items[:0]
for _, item := range items {
if _, ok := pool.CostumeWeaponMap[item.PossessionId]; ok {
filtered = append(filtered, item)
}
}
pool.CostumesByRarity[rarity] = filtered
}
log.Printf("[GachaPool] pruned %d unpaired costumes", pruned)
}
func (pool *GachaCatalog) BuildFeaturedMapping(entries []store.GachaCatalogEntry) {
matched := 0
for _, entry := range entries {
if entry.MedalConsumableItemId == 0 {
continue
}
shopEntries, ok := pool.ShopFeaturedByMedal[entry.MedalConsumableItemId]
if !ok || len(shopEntries) == 0 {
continue
}
seenCostume := make(map[int32]bool)
linkedWeapons := make(map[int32]bool)
var costumes []GachaPoolItem
for _, se := range shopEntries {
if se.CostumeId != 0 && !seenCostume[se.CostumeId] {
costumes = append(costumes, pool.CostumeById[se.CostumeId])
seenCostume[se.CostumeId] = true
linkedWeapons[se.WeaponId] = true
}
}
seenWeapon := make(map[int32]bool)
var weapons []GachaPoolItem
for _, se := range shopEntries {
if se.WeaponId != 0 && !linkedWeapons[se.WeaponId] && !seenWeapon[se.WeaponId] {
if item, ok := pool.WeaponById[se.WeaponId]; ok {
weapons = append(weapons, item)
seenWeapon[se.WeaponId] = true
}
}
}
pool.FeaturedByGacha[entry.GachaId] = FeaturedSet{Costumes: costumes, Weapons: weapons}
matched++
}
log.Printf("[GachaPool] featured mapping: %d/%d banners matched via shop", matched, len(entries))
}
func (pool *GachaCatalog) BuildBannerPools(entries []store.GachaCatalogEntry) {
allFeaturedCostumes := make(map[int32]bool)
allFeaturedWeapons := make(map[int32]bool)
for _, fs := range pool.FeaturedByGacha {
for _, c := range fs.Costumes {
allFeaturedCostumes[c.PossessionId] = true
allFeaturedWeapons[pool.CostumeWeaponMap[c.PossessionId]] = true
}
for _, w := range fs.Weapons {
allFeaturedWeapons[w.PossessionId] = true
}
}
commonCostumes := make(map[int32][]GachaPoolItem)
for rarity, items := range pool.CostumesByRarity {
for _, item := range items {
if !allFeaturedCostumes[item.PossessionId] {
commonCostumes[rarity] = append(commonCostumes[rarity], item)
}
}
}
commonWeapons := make(map[int32][]GachaPoolItem)
for rarity, items := range pool.WeaponsByRarity {
for _, item := range items {
if !allFeaturedWeapons[item.PossessionId] {
commonWeapons[rarity] = append(commonWeapons[rarity], item)
}
}
}
commonPool := &BannerPool{
CostumesByRarity: commonCostumes,
WeaponsByRarity: commonWeapons,
}
pool.BannerPools = make(map[int32]*BannerPool)
for _, entry := range entries {
fs, hasFeatured := pool.FeaturedByGacha[entry.GachaId]
if !hasFeatured {
pool.BannerPools[entry.GachaId] = commonPool
continue
}
var allFeatured []GachaPoolItem
bannerCostumes := make(map[int32][]GachaPoolItem)
for rarity, items := range commonCostumes {
bannerCostumes[rarity] = append(bannerCostumes[rarity], items...)
}
bannerWeapons := make(map[int32][]GachaPoolItem)
for rarity, items := range commonWeapons {
bannerWeapons[rarity] = append(bannerWeapons[rarity], items...)
}
for _, c := range fs.Costumes {
bannerCostumes[c.RarityType] = append(bannerCostumes[c.RarityType], c)
allFeatured = append(allFeatured, c)
wid := pool.CostumeWeaponMap[c.PossessionId]
w := pool.WeaponById[wid]
bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w)
allFeatured = append(allFeatured, w)
}
for _, w := range fs.Weapons {
bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w)
allFeatured = append(allFeatured, w)
}
pool.BannerPools[entry.GachaId] = &BannerPool{
CostumesByRarity: bannerCostumes,
WeaponsByRarity: bannerWeapons,
Featured: allFeatured,
}
}
log.Printf("[GachaPool] banner pools: %d banners, %d featured costumes stripped, %d featured weapons stripped",
len(pool.BannerPools), len(allFeaturedCostumes), len(allFeaturedWeapons))
}
func buildEvolvedWeaponSet(rows []WeaponEvolutionGroupRow) map[int32]bool {
grouped := make(map[int32][]WeaponEvolutionGroupRow)
for _, r := range rows {
grouped[r.WeaponEvolutionGroupId] = append(grouped[r.WeaponEvolutionGroupId], r)
}
evolved := make(map[int32]bool)
for _, chain := range grouped {
sort.Slice(chain, func(i, j int) bool {
return chain[i].EvolutionOrder < chain[j].EvolutionOrder
})
for i := 1; i < len(chain); i++ {
evolved[chain[i].WeaponId] = true
}
}
return evolved
}
+76
View File
@@ -0,0 +1,76 @@
package masterdata
import (
"fmt"
"log"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils"
)
type gimmickScheduleRow struct {
GimmickSequenceScheduleId int32 `json:"GimmickSequenceScheduleId"`
StartDatetime int64 `json:"StartDatetime"`
EndDatetime int64 `json:"EndDatetime"`
FirstGimmickSequenceId int32 `json:"FirstGimmickSequenceId"`
ReleaseEvaluateConditionId int32 `json:"ReleaseEvaluateConditionId"`
}
type gimmickScheduleEntry struct {
ScheduleId int32
StartDatetime int64
EndDatetime int64
FirstSequenceId int32
RequiredQuestId int32 // 0 = always active
}
type GimmickCatalog struct {
schedules []gimmickScheduleEntry
}
func LoadGimmickCatalog(resolver *ConditionResolver) (*GimmickCatalog, error) {
rows, err := utils.ReadJSON[gimmickScheduleRow]("EntityMGimmickSequenceScheduleTable.json")
if err != nil {
return nil, fmt.Errorf("load gimmick sequence schedule table: %w", err)
}
entries := make([]gimmickScheduleEntry, 0, len(rows))
for _, r := range rows {
entry := gimmickScheduleEntry{
ScheduleId: r.GimmickSequenceScheduleId,
StartDatetime: r.StartDatetime,
EndDatetime: r.EndDatetime,
FirstSequenceId: r.FirstGimmickSequenceId,
}
if r.ReleaseEvaluateConditionId != 0 {
if qid, ok := resolver.RequiredQuestId(r.ReleaseEvaluateConditionId); ok {
entry.RequiredQuestId = qid
}
}
entries = append(entries, entry)
}
log.Printf("gimmick catalog loaded: %d schedules", len(entries))
return &GimmickCatalog{schedules: entries}, nil
}
func (c *GimmickCatalog) ActiveScheduleKeys(user store.UserState, nowMillis int64) []store.GimmickSequenceKey {
var keys []store.GimmickSequenceKey
for _, s := range c.schedules {
if nowMillis < s.StartDatetime || nowMillis > s.EndDatetime {
continue
}
if s.RequiredQuestId != 0 {
q, ok := user.Quests[s.RequiredQuestId]
if !ok || q.QuestStateType != model.UserQuestStateTypeCleared {
continue
}
}
keys = append(keys, store.GimmickSequenceKey{
GimmickSequenceScheduleId: s.ScheduleId,
GimmickSequenceId: s.FirstSequenceId,
})
}
return keys
}
+55
View File
@@ -0,0 +1,55 @@
package masterdata
import (
"log"
"lunar-tear/server/internal/utils"
)
type loginBonusStamp struct {
LoginBonusId int32 `json:"LoginBonusId"`
LowerPageNumber int32 `json:"LowerPageNumber"`
StampNumber int32 `json:"StampNumber"`
RewardPossessionType int32 `json:"RewardPossessionType"`
RewardPossessionId int32 `json:"RewardPossessionId"`
RewardCount int32 `json:"RewardCount"`
}
type loginBonusStampKey struct {
LoginBonusId int32
LowerPageNumber int32
StampNumber int32
}
type LoginBonusReward struct {
PossessionType int32
PossessionId int32
Count int32
}
type LoginBonusCatalog struct {
stamps map[loginBonusStampKey]LoginBonusReward
}
func (c *LoginBonusCatalog) LookupStampReward(loginBonusId, pageNumber, stampNumber int32) (LoginBonusReward, bool) {
entry, ok := c.stamps[loginBonusStampKey{loginBonusId, pageNumber, stampNumber}]
return entry, ok
}
func LoadLoginBonusCatalog() *LoginBonusCatalog {
stamps, err := utils.ReadJSON[loginBonusStamp]("EntityMLoginBonusStampTable.json")
if err != nil {
log.Fatalf("load login bonus stamp table: %v", err)
}
cat := &LoginBonusCatalog{
stamps: make(map[loginBonusStampKey]LoginBonusReward, len(stamps)),
}
for _, s := range stamps {
cat.stamps[loginBonusStampKey{s.LoginBonusId, s.LowerPageNumber, s.StampNumber}] = LoginBonusReward{
PossessionType: s.RewardPossessionType,
PossessionId: s.RewardPossessionId,
Count: s.RewardCount,
}
}
return cat
}
+71
View File
@@ -0,0 +1,71 @@
package masterdata
import (
"fmt"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/utils"
)
type MaterialRow struct {
MaterialId int32 `json:"MaterialId"`
MaterialType model.MaterialType `json:"MaterialType"`
WeaponType int32 `json:"WeaponType"`
EffectValue int32 `json:"EffectValue"`
SellPrice int32 `json:"SellPrice"`
}
type numericalParameterMapRow struct {
NumericalParameterMapId int32 `json:"NumericalParameterMapId"`
ParameterKey int32 `json:"ParameterKey"`
ParameterValue int32 `json:"ParameterValue"`
}
func LoadParameterMap() ([]numericalParameterMapRow, error) {
rows, err := utils.ReadJSON[numericalParameterMapRow]("EntityMNumericalParameterMapTable.json")
if err != nil {
return nil, fmt.Errorf("load numerical parameter map table: %w", err)
}
return rows, nil
}
func BuildExpThresholds(paramMapRows []numericalParameterMapRow, mapId int32) []int32 {
maxKey := int32(0)
for _, r := range paramMapRows {
if r.NumericalParameterMapId == mapId && r.ParameterKey > maxKey {
maxKey = r.ParameterKey
}
}
thresholds := make([]int32, maxKey+1)
for _, r := range paramMapRows {
if r.NumericalParameterMapId == mapId {
thresholds[r.ParameterKey] = r.ParameterValue
}
}
return thresholds
}
type MaterialCatalog struct {
All map[int32]MaterialRow
ByType map[model.MaterialType]map[int32]MaterialRow
}
func LoadMaterialCatalog() (*MaterialCatalog, error) {
rows, err := utils.ReadJSON[MaterialRow]("EntityMMaterialTable.json")
if err != nil {
return nil, fmt.Errorf("load material table: %w", err)
}
catalog := &MaterialCatalog{
All: make(map[int32]MaterialRow, len(rows)),
ByType: make(map[model.MaterialType]map[int32]MaterialRow),
}
for _, row := range rows {
catalog.All[row.MaterialId] = row
if catalog.ByType[row.MaterialType] == nil {
catalog.ByType[row.MaterialType] = make(map[int32]MaterialRow)
}
catalog.ByType[row.MaterialType][row.MaterialId] = row
}
return catalog, nil
}
+106
View File
@@ -0,0 +1,106 @@
package masterdata
import (
"fmt"
"sort"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/utils"
)
type numericalFunctionRow struct {
NumericalFunctionId int32 `json:"NumericalFunctionId"`
NumericalFunctionType int32 `json:"NumericalFunctionType"`
NumericalFunctionParameterGroupId int32 `json:"NumericalFunctionParameterGroupId"`
}
type numericalFunctionParameterRow struct {
NumericalFunctionParameterGroupId int32 `json:"NumericalFunctionParameterGroupId"`
ParameterIndex int32 `json:"ParameterIndex"`
ParameterValue int32 `json:"ParameterValue"`
}
type NumericalFunc struct {
Type model.NumericalFunctionType
Params []int32
}
func (f NumericalFunc) Evaluate(value int32) int32 {
p := f.Params
switch f.Type {
case model.NumericalFunctionTypeLinear:
return p[1] + p[0]*value
case model.NumericalFunctionTypeMonomial:
v := value - 1
result := v
counter := p[1]
if counter > 1 {
counter--
for counter > 0 {
counter--
result *= v
}
}
return result * p[0]
case model.NumericalFunctionTypeLinearPermil:
return p[0]*value/1000 + p[1]
case model.NumericalFunctionTypePolynomialThird:
return p[3] + (p[2]+(p[1]+p[0]*value)*value)*value
case model.NumericalFunctionTypePolynomialThirdPermil:
return p[0]*value*value*value/1000 +
p[1]*value*value/1000 +
p[2]*value/1000 +
p[3]
default:
return 0
}
}
type FunctionResolver struct {
functions map[int32]NumericalFunc
}
func LoadFunctionResolver() (*FunctionResolver, error) {
funcRows, err := utils.ReadJSON[numericalFunctionRow]("EntityMNumericalFunctionTable.json")
if err != nil {
return nil, fmt.Errorf("load numerical function table: %w", err)
}
paramRows, err := utils.ReadJSON[numericalFunctionParameterRow]("EntityMNumericalFunctionParameterGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load numerical function parameter group table: %w", err)
}
paramsByGroup := make(map[int32][]numericalFunctionParameterRow, len(paramRows))
for _, r := range paramRows {
paramsByGroup[r.NumericalFunctionParameterGroupId] = append(
paramsByGroup[r.NumericalFunctionParameterGroupId], r)
}
for _, group := range paramsByGroup {
sort.Slice(group, func(i, j int) bool {
return group[i].ParameterIndex < group[j].ParameterIndex
})
}
functions := make(map[int32]NumericalFunc, len(funcRows))
for _, fr := range funcRows {
group := paramsByGroup[fr.NumericalFunctionParameterGroupId]
params := make([]int32, len(group))
for _, pr := range group {
if int(pr.ParameterIndex) < len(params) {
params[pr.ParameterIndex] = pr.ParameterValue
}
}
functions[fr.NumericalFunctionId] = NumericalFunc{
Type: model.NumericalFunctionType(fr.NumericalFunctionType),
Params: params,
}
}
return &FunctionResolver{functions: functions}, nil
}
func (r *FunctionResolver) Resolve(functionId int32) (NumericalFunc, bool) {
f, ok := r.functions[functionId]
return f, ok
}
+37
View File
@@ -0,0 +1,37 @@
package masterdata
import (
"log"
"lunar-tear/server/internal/utils"
)
type omikujiEntry struct {
OmikujiId int32 `json:"OmikujiId"`
OmikujiAssetId int32 `json:"OmikujiAssetId"`
}
type OmikujiCatalog struct {
assetIds map[int32]int32
}
func (c *OmikujiCatalog) LookupAssetId(omikujiId int32) int32 {
if id, ok := c.assetIds[omikujiId]; ok {
return id
}
return 0
}
func LoadOmikujiCatalog() *OmikujiCatalog {
entries, err := utils.ReadJSON[omikujiEntry]("EntityMOmikujiTable.json")
if err != nil {
log.Fatalf("load omikuji table: %v", err)
}
cat := &OmikujiCatalog{
assetIds: make(map[int32]int32, len(entries)),
}
for _, e := range entries {
cat.assetIds[e.OmikujiId] = e.OmikujiAssetId
}
return cat
}
+120
View File
@@ -0,0 +1,120 @@
package masterdata
import (
"fmt"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/utils"
)
type PartsRow struct {
PartsId int32 `json:"PartsId"`
RarityType model.RarityType `json:"RarityType"`
PartsGroupId int32 `json:"PartsGroupId"`
PartsStatusMainLotteryGroupId int32 `json:"PartsStatusMainLotteryGroupId"`
}
type PartsRarityRow struct {
RarityType model.RarityType `json:"RarityType"`
PartsLevelUpRateGroupId int32 `json:"PartsLevelUpRateGroupId"`
PartsLevelUpPriceGroupId int32 `json:"PartsLevelUpPriceGroupId"`
SellPriceNumericalFunctionId int32 `json:"SellPriceNumericalFunctionId"`
}
type partsLevelUpRateRow struct {
PartsLevelUpRateGroupId int32 `json:"PartsLevelUpRateGroupId"`
LevelLowerLimit int32 `json:"LevelLowerLimit"`
SuccessRatePermil int32 `json:"SuccessRatePermil"`
}
type partsLevelUpPriceRow struct {
PartsLevelUpPriceGroupId int32 `json:"PartsLevelUpPriceGroupId"`
LevelLowerLimit int32 `json:"LevelLowerLimit"`
Gold int32 `json:"Gold"`
}
type PartsCatalog struct {
PartsById map[int32]PartsRow
DefaultPartsStatusMainByLotteryGroup map[int32]int32
RarityByRarityType map[model.RarityType]PartsRarityRow
RateByGroupAndLevel map[int32]map[int32]int32
PriceByGroupAndLevel map[int32]map[int32]int32
SellPriceByRarity map[model.RarityType]NumericalFunc
}
func LoadPartsCatalog() (*PartsCatalog, error) {
partsRows, err := utils.ReadJSON[PartsRow]("EntityMPartsTable.json")
if err != nil {
return nil, fmt.Errorf("load parts table: %w", err)
}
rarityRows, err := utils.ReadJSON[PartsRarityRow]("EntityMPartsRarityTable.json")
if err != nil {
return nil, fmt.Errorf("load parts rarity table: %w", err)
}
rateRows, err := utils.ReadJSON[partsLevelUpRateRow]("EntityMPartsLevelUpRateGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load parts level up rate table: %w", err)
}
priceRows, err := utils.ReadJSON[partsLevelUpPriceRow]("EntityMPartsLevelUpPriceGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load parts level up price table: %w", err)
}
partsById := make(map[int32]PartsRow, len(partsRows))
for _, p := range partsRows {
partsById[p.PartsId] = p
}
// Lottery group ID encodes tier (first digit 1-4) and stat category
// (second digit 1-6). Formula: mainStatId = (category - 1) * 4 + tier.
defaultPartsStatusMainByLotteryGroup := make(map[int32]int32, 24)
for tier := int32(1); tier <= 4; tier++ {
for cat := int32(1); cat <= 6; cat++ {
groupId := tier*10 + cat
mainStatId := (cat-1)*4 + tier
defaultPartsStatusMainByLotteryGroup[groupId] = mainStatId
}
}
funcResolver, err := LoadFunctionResolver()
if err != nil {
return nil, fmt.Errorf("load function resolver: %w", err)
}
rarityByRarityType := make(map[model.RarityType]PartsRarityRow, len(rarityRows))
sellPriceByRarity := make(map[model.RarityType]NumericalFunc, len(rarityRows))
for _, r := range rarityRows {
rarityByRarityType[r.RarityType] = r
if f, ok := funcResolver.Resolve(r.SellPriceNumericalFunctionId); ok {
sellPriceByRarity[r.RarityType] = f
}
}
rateByGroupAndLevel := make(map[int32]map[int32]int32)
for _, r := range rateRows {
if rateByGroupAndLevel[r.PartsLevelUpRateGroupId] == nil {
rateByGroupAndLevel[r.PartsLevelUpRateGroupId] = make(map[int32]int32)
}
rateByGroupAndLevel[r.PartsLevelUpRateGroupId][r.LevelLowerLimit] = r.SuccessRatePermil
}
priceByGroupAndLevel := make(map[int32]map[int32]int32)
for _, p := range priceRows {
if priceByGroupAndLevel[p.PartsLevelUpPriceGroupId] == nil {
priceByGroupAndLevel[p.PartsLevelUpPriceGroupId] = make(map[int32]int32)
}
priceByGroupAndLevel[p.PartsLevelUpPriceGroupId][p.LevelLowerLimit] = p.Gold
}
return &PartsCatalog{
PartsById: partsById,
DefaultPartsStatusMainByLotteryGroup: defaultPartsStatusMainByLotteryGroup,
RarityByRarityType: rarityByRarityType,
RateByGroupAndLevel: rateByGroupAndLevel,
PriceByGroupAndLevel: priceByGroupAndLevel,
SellPriceByRarity: sellPriceByRarity,
}, nil
}
+727
View File
@@ -0,0 +1,727 @@
package masterdata
import (
"fmt"
"sort"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/utils"
)
type QuestSceneRow struct {
QuestSceneId int32 `json:"QuestSceneId"`
QuestId int32 `json:"QuestId"`
SortOrder int32 `json:"SortOrder"`
QuestSceneType model.QuestSceneType `json:"QuestSceneType"`
AssetBackgroundId int32 `json:"AssetBackgroundId"`
EventMapNumberUpper int32 `json:"EventMapNumberUpper"`
EventMapNumberLower int32 `json:"EventMapNumberLower"`
IsMainFlowQuestTarget bool `json:"IsMainFlowQuestTarget"`
IsBattleOnlyTarget bool `json:"IsBattleOnlyTarget"`
QuestResultType model.QuestResultType `json:"QuestResultType"`
IsStorySkipTarget bool `json:"IsStorySkipTarget"`
}
type QuestRow struct {
QuestId int32 `json:"QuestId"`
NameQuestTextId int32 `json:"NameQuestTextId"`
PictureBookNameQuestTextId int32 `json:"PictureBookNameQuestTextId"`
QuestReleaseConditionListId int32 `json:"QuestReleaseConditionListId"`
StoryQuestTextId int32 `json:"StoryQuestTextId"`
QuestDisplayAttributeGroupId int32 `json:"QuestDisplayAttributeGroupId"`
RecommendedDeckPower int32 `json:"RecommendedDeckPower"`
QuestFirstClearRewardGroupId int32 `json:"QuestFirstClearRewardGroupId"`
QuestPickupRewardGroupId int32 `json:"QuestPickupRewardGroupId"`
QuestDeckRestrictionGroupId int32 `json:"QuestDeckRestrictionGroupId"`
QuestMissionGroupId int32 `json:"QuestMissionGroupId"`
Stamina int32 `json:"Stamina"`
UserExp int32 `json:"UserExp"`
CharacterExp int32 `json:"CharacterExp"`
CostumeExp int32 `json:"CostumeExp"`
Gold int32 `json:"Gold"`
DailyClearableCount int32 `json:"DailyClearableCount"`
IsRunInTheBackground bool `json:"IsRunInTheBackground"`
IsCountedAsQuest bool `json:"IsCountedAsQuest"`
QuestBonusId int32 `json:"QuestBonusId"`
IsNotShowAfterClear bool `json:"IsNotShowAfterClear"`
IsBigWinTarget bool `json:"IsBigWinTarget"`
IsUsableSkipTicket bool `json:"IsUsableSkipTicket"`
QuestReplayFlowRewardGroupId int32 `json:"QuestReplayFlowRewardGroupId"`
InvisibleQuestMissionGroupId int32 `json:"InvisibleQuestMissionGroupId"`
FieldEffectGroupId int32 `json:"FieldEffectGroupId"`
}
type QuestMissionRow struct {
QuestMissionId int32 `json:"QuestMissionId"`
QuestMissionConditionType model.QuestMissionConditionType `json:"QuestMissionConditionType"`
QuestMissionRewardId int32 `json:"QuestMissionRewardId"`
QuestMissionConditionValueGroupId int32 `json:"QuestMissionConditionValueGroupId"`
}
type QuestMissionGroupRow struct {
QuestMissionGroupId int32 `json:"QuestMissionGroupId"`
SortOrder int32 `json:"SortOrder"`
QuestMissionId int32 `json:"QuestMissionId"`
}
type QuestMissionRewardRow struct {
QuestMissionRewardId int32 `json:"QuestMissionRewardId"`
PossessionType model.PossessionType `json:"PossessionType"`
PossessionId int32 `json:"PossessionId"`
Count int32 `json:"Count"`
}
type MainQuestSequenceRow struct {
MainQuestSequenceId int32 `json:"MainQuestSequenceId"`
SortOrder int32 `json:"SortOrder"`
QuestId int32 `json:"QuestId"`
}
type MainQuestRouteRow struct {
MainQuestRouteId int32 `json:"MainQuestRouteId"`
MainQuestSeasonId int32 `json:"MainQuestSeasonId"`
SortOrder int32 `json:"SortOrder"`
CharacterId int32 `json:"CharacterId"`
}
type MainQuestChapterRow struct {
MainQuestChapterId int32 `json:"MainQuestChapterId"`
MainQuestRouteId int32 `json:"MainQuestRouteId"`
SortOrder int32 `json:"SortOrder"`
MainQuestSequenceGroupId int32 `json:"MainQuestSequenceGroupId"`
PortalCageCharacterGroupId int32 `json:"PortalCageCharacterGroupId"`
StartDatetime int64 `json:"StartDatetime"`
IsInvisibleInLibrary bool `json:"IsInvisibleInLibrary"`
JoinLibraryChapterId int32 `json:"JoinLibraryChapterId"`
}
type QuestFirstClearRewardSwitchRow struct {
QuestId int32 `json:"QuestId"`
QuestFirstClearRewardGroupId int32 `json:"QuestFirstClearRewardGroupId"`
SwitchConditionClearQuestId int32 `json:"SwitchConditionClearQuestId"`
}
type QuestFirstClearRewardGroupRow struct {
QuestFirstClearRewardGroupId int32 `json:"QuestFirstClearRewardGroupId"`
QuestFirstClearRewardType int32 `json:"QuestFirstClearRewardType"`
SortOrder int32 `json:"SortOrder"`
PossessionType model.PossessionType `json:"PossessionType"`
PossessionId int32 `json:"PossessionId"`
Count int32 `json:"Count"`
IsPickup bool `json:"IsPickup"`
}
type QuestReplayFlowRewardGroupRow struct {
QuestReplayFlowRewardGroupId int32 `json:"QuestReplayFlowRewardGroupId"`
SortOrder int32 `json:"SortOrder"`
PossessionType model.PossessionType `json:"PossessionType"`
PossessionId int32 `json:"PossessionId"`
Count int32 `json:"Count"`
}
type QuestSceneGrantRow struct {
QuestSceneId int32 `json:"QuestSceneId"`
PossessionType model.PossessionType `json:"PossessionType"`
PossessionId int32 `json:"PossessionId"`
Count int32 `json:"Count"`
}
type QuestPickupRewardGroupRow struct {
QuestPickupRewardGroupId int32 `json:"QuestPickupRewardGroupId"`
SortOrder int32 `json:"SortOrder"`
BattleDropRewardId int32 `json:"BattleDropRewardId"`
}
type BattleDropRewardRow struct {
BattleDropRewardId int32 `json:"BattleDropRewardId"`
PossessionType model.PossessionType `json:"PossessionType"`
PossessionId int32 `json:"PossessionId"`
Count int32 `json:"Count"`
}
type QuestSceneBattleRow struct {
QuestSceneId int32 `json:"QuestSceneId"`
BattleGroupId int32 `json:"BattleGroupId"`
}
type BattleGroupRow struct {
BattleGroupId int32 `json:"BattleGroupId"`
WaveNumber int32 `json:"WaveNumber"`
BattleId int32 `json:"BattleId"`
}
type BattleRow struct {
BattleId int32 `json:"BattleId"`
BattleNpcId int32 `json:"BattleNpcId"`
DeckType model.DeckType `json:"DeckType"`
BattleNpcDeckNumber int32 `json:"BattleNpcDeckNumber"`
}
type BattleNpcDeckRow struct {
BattleNpcId int32 `json:"BattleNpcId"`
DeckType model.DeckType `json:"DeckType"`
BattleNpcDeckNumber int32 `json:"BattleNpcDeckNumber"`
BattleNpcDeckCharacterUuid01 string `json:"BattleNpcDeckCharacterUuid01"`
BattleNpcDeckCharacterUuid02 string `json:"BattleNpcDeckCharacterUuid02"`
BattleNpcDeckCharacterUuid03 string `json:"BattleNpcDeckCharacterUuid03"`
}
type BattleNpcDropCategoryRow struct {
BattleNpcId int32 `json:"BattleNpcId"`
BattleNpcDeckCharacterUuid string `json:"BattleNpcDeckCharacterUuid"`
BattleDropCategoryId int32 `json:"BattleDropCategoryId"`
}
type BattleDropInfo struct {
QuestSceneId int32
BattleDropCategoryId int32
}
type TutorialUnlockConditionRow struct {
TutorialType int32 `json:"TutorialType"`
TutorialUnlockConditionType int32 `json:"TutorialUnlockConditionType"`
ConditionValue int32 `json:"ConditionValue"`
}
type RentalDeckRow struct {
BattleGroupId int32 `json:"BattleGroupId"`
}
type UserLevelRow struct {
UserLevel int32 `json:"UserLevel"`
MaxStamina int32 `json:"MaxStamina"`
}
type QuestCatalog struct {
SceneById map[int32]QuestSceneRow
MissionById map[int32]QuestMissionRow
QuestById map[int32]QuestRow
MissionIdsByQuestId map[int32][]int32
RouteIdByQuestId map[int32]int32
SceneIdsByQuestId map[int32][]int32
OrderedQuestIds []int32
FirstClearRewardsByGroupId map[int32][]QuestFirstClearRewardGroupRow
FirstClearRewardSwitchesByQuestId map[int32][]QuestFirstClearRewardSwitchRow
MissionRewardsByMissionId map[int32][]QuestMissionRewardRow
WeaponIdsByReleaseConditionGroupId map[int32][]int32
ReleaseConditionsByGroupId map[int32][]WeaponStoryReleaseConditionRow
SceneGrantsBySceneId map[int32][]QuestSceneGrantRow
BattleDropRewardById map[int32]BattleDropRewardRow
PickupRewardIdsByGroupId map[int32][]int32
BattleDropsByQuestId map[int32][]BattleDropInfo
ReplayFlowRewardsByGroupId map[int32][]QuestReplayFlowRewardGroupRow
RentalQuestIds map[int32]bool
TutorialUnlockConditions []TutorialUnlockConditionRow
ChapterLastSceneByQuestId map[int32]int32
SeasonIdByRouteId map[int32]int32
UserExpThresholds []int32
CharacterExpThresholds []int32
CostumeExpByRarity map[int32][]int32
CostumeMaxLevelByRarity map[int32]NumericalFunc
MaxStaminaByLevel map[int32]int32
CostumeById map[int32]CostumeMasterRow
WeaponById map[int32]WeaponMasterRow
WeaponSkillSlots map[int32][]int32
WeaponAbilitySlots map[int32][]int32
*PartsCatalog
}
func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
scenes, err := utils.ReadJSON[QuestSceneRow]("EntityMQuestSceneTable.json")
if err != nil {
return nil, fmt.Errorf("load quest scene table: %w", err)
}
sort.Slice(scenes, func(i, j int) bool {
if scenes[i].QuestId != scenes[j].QuestId {
return scenes[i].QuestId < scenes[j].QuestId
}
if scenes[i].SortOrder != scenes[j].SortOrder {
return scenes[i].SortOrder < scenes[j].SortOrder
}
return scenes[i].QuestSceneId < scenes[j].QuestSceneId
})
missions, err := utils.ReadJSON[QuestMissionRow]("EntityMQuestMissionTable.json")
if err != nil {
return nil, fmt.Errorf("load quest mission table: %w", err)
}
quests, err := utils.ReadJSON[QuestRow]("EntityMQuestTable.json")
if err != nil {
return nil, fmt.Errorf("load quest table: %w", err)
}
missionGroups, err := utils.ReadJSON[QuestMissionGroupRow]("EntityMQuestMissionGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load quest mission group table: %w", err)
}
sort.Slice(missionGroups, func(i, j int) bool {
if missionGroups[i].QuestMissionGroupId != missionGroups[j].QuestMissionGroupId {
return missionGroups[i].QuestMissionGroupId < missionGroups[j].QuestMissionGroupId
}
if missionGroups[i].SortOrder != missionGroups[j].SortOrder {
return missionGroups[i].SortOrder < missionGroups[j].SortOrder
}
return missionGroups[i].QuestMissionId < missionGroups[j].QuestMissionId
})
sequences, err := utils.ReadJSON[MainQuestSequenceRow]("EntityMMainQuestSequenceTable.json")
if err != nil {
return nil, fmt.Errorf("load main quest sequence table: %w", err)
}
sort.Slice(sequences, func(i, j int) bool {
if sequences[i].MainQuestSequenceId != sequences[j].MainQuestSequenceId {
return sequences[i].MainQuestSequenceId < sequences[j].MainQuestSequenceId
}
if sequences[i].SortOrder != sequences[j].SortOrder {
return sequences[i].SortOrder < sequences[j].SortOrder
}
return sequences[i].QuestId < sequences[j].QuestId
})
chapters, err := utils.ReadJSON[MainQuestChapterRow]("EntityMMainQuestChapterTable.json")
if err != nil {
return nil, fmt.Errorf("load main quest chapter table: %w", err)
}
routes, err := utils.ReadJSON[MainQuestRouteRow]("EntityMMainQuestRouteTable.json")
if err != nil {
return nil, fmt.Errorf("load main quest route table: %w", err)
}
seasonIdByRouteId := make(map[int32]int32, len(routes))
for _, r := range routes {
seasonIdByRouteId[r.MainQuestRouteId] = r.MainQuestSeasonId
}
firstClearSwitches, err := utils.ReadJSON[QuestFirstClearRewardSwitchRow]("EntityMQuestFirstClearRewardSwitchTable.json")
if err != nil {
return nil, fmt.Errorf("load quest first clear reward switch table: %w", err)
}
firstClearRewards, err := utils.ReadJSON[QuestFirstClearRewardGroupRow]("EntityMQuestFirstClearRewardGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load quest first clear reward group table: %w", err)
}
sort.Slice(firstClearRewards, func(i, j int) bool {
if firstClearRewards[i].QuestFirstClearRewardGroupId != firstClearRewards[j].QuestFirstClearRewardGroupId {
return firstClearRewards[i].QuestFirstClearRewardGroupId < firstClearRewards[j].QuestFirstClearRewardGroupId
}
if firstClearRewards[i].SortOrder != firstClearRewards[j].SortOrder {
return firstClearRewards[i].SortOrder < firstClearRewards[j].SortOrder
}
return firstClearRewards[i].QuestFirstClearRewardType < firstClearRewards[j].QuestFirstClearRewardType
})
replayFlowRewards, err := utils.ReadJSON[QuestReplayFlowRewardGroupRow]("EntityMQuestReplayFlowRewardGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load quest replay flow reward group table: %w", err)
}
sort.Slice(replayFlowRewards, func(i, j int) bool {
if replayFlowRewards[i].QuestReplayFlowRewardGroupId != replayFlowRewards[j].QuestReplayFlowRewardGroupId {
return replayFlowRewards[i].QuestReplayFlowRewardGroupId < replayFlowRewards[j].QuestReplayFlowRewardGroupId
}
return replayFlowRewards[i].SortOrder < replayFlowRewards[j].SortOrder
})
missionRewards, err := utils.ReadJSON[QuestMissionRewardRow]("EntityMQuestMissionRewardTable.json")
if err != nil {
return nil, fmt.Errorf("load quest mission reward table: %w", err)
}
weapons, err := utils.ReadJSON[WeaponMasterRow]("EntityMWeaponTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon table: %w", err)
}
weaponSkillGroups, err := utils.ReadJSON[WeaponSkillGroupRow]("EntityMWeaponSkillGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon skill group table: %w", err)
}
weaponAbilityGroups, err := utils.ReadJSON[WeaponAbilityGroupRow]("EntityMWeaponAbilityGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon ability group table: %w", err)
}
releaseConditions, err := utils.ReadJSON[WeaponStoryReleaseConditionRow]("EntityMWeaponStoryReleaseConditionGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon story release condition table: %w", err)
}
costumeMasters, err := utils.ReadJSON[CostumeMasterRow]("EntityMCostumeTable.json")
if err != nil {
return nil, fmt.Errorf("load costume table: %w", err)
}
costumeRarities, err := utils.ReadJSON[costumeRarityRow]("EntityMCostumeRarityTable.json")
if err != nil {
return nil, fmt.Errorf("load costume rarity table: %w", err)
}
sceneGrants, err := utils.ReadJSON[QuestSceneGrantRow]("EntityMUserQuestSceneGrantPossessionTable.json")
if err != nil {
return nil, fmt.Errorf("load quest scene grant table: %w", err)
}
battleDropRewards, err := utils.ReadJSON[BattleDropRewardRow]("EntityMBattleDropRewardTable.json")
if err != nil {
return nil, fmt.Errorf("load battle drop reward table: %w", err)
}
pickupRewardGroups, err := utils.ReadJSON[QuestPickupRewardGroupRow]("EntityMQuestPickupRewardGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load quest pickup reward group table: %w", err)
}
sort.Slice(pickupRewardGroups, func(i, j int) bool {
if pickupRewardGroups[i].QuestPickupRewardGroupId != pickupRewardGroups[j].QuestPickupRewardGroupId {
return pickupRewardGroups[i].QuestPickupRewardGroupId < pickupRewardGroups[j].QuestPickupRewardGroupId
}
return pickupRewardGroups[i].SortOrder < pickupRewardGroups[j].SortOrder
})
sceneBattles, err := utils.ReadJSON[QuestSceneBattleRow]("EntityMQuestSceneBattleTable.json")
if err != nil {
return nil, fmt.Errorf("load quest scene battle table: %w", err)
}
battleGroups, err := utils.ReadJSON[BattleGroupRow]("EntityMBattleGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load battle group table: %w", err)
}
battles, err := utils.ReadJSON[BattleRow]("EntityMBattleTable.json")
if err != nil {
return nil, fmt.Errorf("load battle table: %w", err)
}
npcDecks, err := utils.ReadJSON[BattleNpcDeckRow]("EntityMBattleNpcDeckTable.json")
if err != nil {
return nil, fmt.Errorf("load battle npc deck table: %w", err)
}
npcDropCategories, err := utils.ReadJSON[BattleNpcDropCategoryRow]("EntityMBattleNpcDeckCharacterDropCategoryTable.json")
if err != nil {
return nil, fmt.Errorf("load battle npc drop category table: %w", err)
}
rentalDecks, err := utils.ReadJSON[RentalDeckRow]("EntityMBattleRentalDeckTable.json")
if err != nil {
return nil, fmt.Errorf("load battle rental deck table: %w", err)
}
tutorialUnlockConds, err := utils.ReadJSON[TutorialUnlockConditionRow]("EntityMTutorialUnlockConditionTable.json")
if err != nil {
return nil, fmt.Errorf("load tutorial unlock condition table: %w", err)
}
paramMapRows, err := LoadParameterMap()
if err != nil {
return nil, err
}
userLevels, err := utils.ReadJSON[UserLevelRow]("EntityMUserLevelTable.json")
if err != nil {
return nil, fmt.Errorf("load user level table: %w", err)
}
maxStaminaByLevel := make(map[int32]int32, len(userLevels))
for _, ul := range userLevels {
maxStaminaByLevel[ul.UserLevel] = ul.MaxStamina
}
funcResolver, err := LoadFunctionResolver()
if err != nil {
return nil, fmt.Errorf("load function resolver: %w", err)
}
costumeExpByRarity := make(map[int32][]int32, len(costumeRarities))
costumeMaxLevelByRarity := make(map[int32]NumericalFunc, len(costumeRarities))
for _, r := range costumeRarities {
if _, ok := costumeExpByRarity[r.RarityType]; !ok {
costumeExpByRarity[r.RarityType] = BuildExpThresholds(paramMapRows, r.RequiredExpForLevelUpNumericalParameterMapId)
}
if _, ok := costumeMaxLevelByRarity[r.RarityType]; !ok {
if f, found := funcResolver.Resolve(r.MaxLevelNumericalFunctionId); found {
costumeMaxLevelByRarity[r.RarityType] = f
}
}
}
costumeById := make(map[int32]CostumeMasterRow, len(costumeMasters))
for _, cm := range costumeMasters {
costumeById[cm.CostumeId] = cm
}
weaponById := make(map[int32]WeaponMasterRow, len(weapons))
for _, w := range weapons {
weaponById[w.WeaponId] = w
}
skillSlots := make(map[int32][]int32)
for _, row := range weaponSkillGroups {
skillSlots[row.WeaponSkillGroupId] = append(skillSlots[row.WeaponSkillGroupId], row.SlotNumber)
}
abilitySlots := make(map[int32][]int32)
for _, row := range weaponAbilityGroups {
abilitySlots[row.WeaponAbilityGroupId] = append(abilitySlots[row.WeaponAbilityGroupId], row.SlotNumber)
}
sceneById := make(map[int32]QuestSceneRow, len(scenes))
sceneIdsByQuestId := make(map[int32][]int32)
for _, scene := range scenes {
sceneById[scene.QuestSceneId] = scene
sceneIdsByQuestId[scene.QuestId] = append(sceneIdsByQuestId[scene.QuestId], scene.QuestSceneId)
}
missionById := make(map[int32]QuestMissionRow, len(missions))
for _, mission := range missions {
missionById[mission.QuestMissionId] = mission
}
questById := make(map[int32]QuestRow, len(quests))
for _, quest := range quests {
questById[quest.QuestId] = quest
}
missionIdsByGroupId := make(map[int32][]int32, len(missionGroups))
for _, mg := range missionGroups {
missionIdsByGroupId[mg.QuestMissionGroupId] = append(
missionIdsByGroupId[mg.QuestMissionGroupId], mg.QuestMissionId)
}
missionIdsByQuestId := make(map[int32][]int32)
for questId, quest := range questById {
missionIds := missionIdsByGroupId[quest.QuestMissionGroupId]
if len(missionIds) == 0 {
continue
}
missionIdsByQuestId[questId] = append([]int32(nil), missionIds...)
}
chapterBySequenceId := make(map[int32]MainQuestChapterRow, len(chapters))
for _, chapter := range chapters {
chapterBySequenceId[chapter.MainQuestSequenceGroupId] = chapter
}
routeIdByQuestId := make(map[int32]int32)
for _, sequence := range sequences {
if chapter, ok := chapterBySequenceId[sequence.MainQuestSequenceId]; ok {
routeIdByQuestId[sequence.QuestId] = chapter.MainQuestRouteId
}
}
sortedChapters := make([]MainQuestChapterRow, len(chapters))
copy(sortedChapters, chapters)
sort.Slice(sortedChapters, func(i, j int) bool {
return sortedChapters[i].SortOrder < sortedChapters[j].SortOrder
})
sequencesByGroupId := make(map[int32][]MainQuestSequenceRow)
for _, seq := range sequences {
sequencesByGroupId[seq.MainQuestSequenceId] = append(sequencesByGroupId[seq.MainQuestSequenceId], seq)
}
var orderedQuestIds []int32
for _, chapter := range sortedChapters {
for _, seq := range sequencesByGroupId[chapter.MainQuestSequenceGroupId] {
orderedQuestIds = append(orderedQuestIds, seq.QuestId)
}
}
chapterLastSceneByQuestId := make(map[int32]int32)
for _, chapter := range sortedChapters {
seqs := sequencesByGroupId[chapter.MainQuestSequenceGroupId]
var chapterLastScene int32
for i := len(seqs) - 1; i >= 0; i-- {
if sids := sceneIdsByQuestId[seqs[i].QuestId]; len(sids) > 0 {
chapterLastScene = sids[len(sids)-1]
break
}
}
if chapterLastScene != 0 {
for _, seq := range seqs {
chapterLastSceneByQuestId[seq.QuestId] = chapterLastScene
}
}
}
firstClearRewardsByGroupId := make(map[int32][]QuestFirstClearRewardGroupRow, len(firstClearRewards))
for _, reward := range firstClearRewards {
firstClearRewardsByGroupId[reward.QuestFirstClearRewardGroupId] = append(
firstClearRewardsByGroupId[reward.QuestFirstClearRewardGroupId], reward)
}
replayFlowRewardsByGroupId := make(map[int32][]QuestReplayFlowRewardGroupRow, len(replayFlowRewards))
for _, reward := range replayFlowRewards {
replayFlowRewardsByGroupId[reward.QuestReplayFlowRewardGroupId] = append(
replayFlowRewardsByGroupId[reward.QuestReplayFlowRewardGroupId], reward)
}
firstClearRewardSwitchesByQuestId := make(map[int32][]QuestFirstClearRewardSwitchRow, len(firstClearSwitches))
for _, switchRow := range firstClearSwitches {
firstClearRewardSwitchesByQuestId[switchRow.QuestId] = append(
firstClearRewardSwitchesByQuestId[switchRow.QuestId], switchRow)
}
missionRewardsByMissionId := make(map[int32][]QuestMissionRewardRow, len(missionRewards))
for _, reward := range missionRewards {
missionRewardsByMissionId[reward.QuestMissionRewardId] = append(
missionRewardsByMissionId[reward.QuestMissionRewardId], reward)
}
weaponIdsByReleaseConditionGroupId := make(map[int32][]int32)
for _, w := range weaponById {
if w.WeaponStoryReleaseConditionGroupId != 0 {
weaponIdsByReleaseConditionGroupId[w.WeaponStoryReleaseConditionGroupId] = append(
weaponIdsByReleaseConditionGroupId[w.WeaponStoryReleaseConditionGroupId], w.WeaponId)
}
}
releaseConditionsByGroupId := make(map[int32][]WeaponStoryReleaseConditionRow)
for _, c := range releaseConditions {
releaseConditionsByGroupId[c.WeaponStoryReleaseConditionGroupId] = append(
releaseConditionsByGroupId[c.WeaponStoryReleaseConditionGroupId], c)
}
sceneGrantsBySceneId := make(map[int32][]QuestSceneGrantRow)
for _, sg := range sceneGrants {
sceneGrantsBySceneId[sg.QuestSceneId] = append(sceneGrantsBySceneId[sg.QuestSceneId], sg)
}
battleDropRewardById := make(map[int32]BattleDropRewardRow, len(battleDropRewards))
for _, bdr := range battleDropRewards {
battleDropRewardById[bdr.BattleDropRewardId] = bdr
}
pickupRewardIdsByGroupId := make(map[int32][]int32)
for _, pg := range pickupRewardGroups {
pickupRewardIdsByGroupId[pg.QuestPickupRewardGroupId] = append(
pickupRewardIdsByGroupId[pg.QuestPickupRewardGroupId], pg.BattleDropRewardId)
}
battleGroupBySceneId := make(map[int32]int32, len(sceneBattles))
for _, sb := range sceneBattles {
battleGroupBySceneId[sb.QuestSceneId] = sb.BattleGroupId
}
battleIdsByGroupId := make(map[int32][]int32)
for _, bg := range battleGroups {
battleIdsByGroupId[bg.BattleGroupId] = append(battleIdsByGroupId[bg.BattleGroupId], bg.BattleId)
}
type npcDeckKey struct {
BattleNpcId int32
DeckType model.DeckType
BattleNpcDeckNumber int32
}
npcDeckByKey := make(map[npcDeckKey]BattleNpcDeckRow, len(npcDecks))
for _, d := range npcDecks {
npcDeckByKey[npcDeckKey{d.BattleNpcId, d.DeckType, d.BattleNpcDeckNumber}] = d
}
battleByIdMap := make(map[int32]BattleRow, len(battles))
for _, b := range battles {
battleByIdMap[b.BattleId] = b
}
type dropCatKey struct {
BattleNpcId int32
Uuid string
}
dropCategoryByKey := make(map[dropCatKey]int32, len(npcDropCategories))
for _, dc := range npcDropCategories {
dropCategoryByKey[dropCatKey{dc.BattleNpcId, dc.BattleNpcDeckCharacterUuid}] = dc.BattleDropCategoryId
}
battleDropsByQuestId := make(map[int32][]BattleDropInfo)
for questId := range questById {
sids := sceneIdsByQuestId[questId]
seen := make(map[BattleDropInfo]bool)
var drops []BattleDropInfo
for _, sceneId := range sids {
groupId, ok := battleGroupBySceneId[sceneId]
if !ok {
continue
}
for _, battleId := range battleIdsByGroupId[groupId] {
b, ok := battleByIdMap[battleId]
if !ok {
continue
}
dk := npcDeckKey{b.BattleNpcId, b.DeckType, b.BattleNpcDeckNumber}
deck, ok := npcDeckByKey[dk]
if !ok {
continue
}
for _, uuid := range []string{deck.BattleNpcDeckCharacterUuid01, deck.BattleNpcDeckCharacterUuid02, deck.BattleNpcDeckCharacterUuid03} {
if uuid == "" {
continue
}
catId, ok := dropCategoryByKey[dropCatKey{b.BattleNpcId, uuid}]
if !ok {
continue
}
info := BattleDropInfo{QuestSceneId: sceneId, BattleDropCategoryId: catId}
if !seen[info] {
seen[info] = true
drops = append(drops, info)
}
}
}
}
if len(drops) > 0 {
battleDropsByQuestId[questId] = drops
}
}
rentalBattleGroups := make(map[int32]bool, len(rentalDecks))
for _, rd := range rentalDecks {
rentalBattleGroups[rd.BattleGroupId] = true
}
rentalQuestIds := make(map[int32]bool)
for questId := range questById {
for _, sceneId := range sceneIdsByQuestId[questId] {
if groupId, ok := battleGroupBySceneId[sceneId]; ok && rentalBattleGroups[groupId] {
rentalQuestIds[questId] = true
break
}
}
}
return &QuestCatalog{
SceneById: sceneById,
MissionById: missionById,
QuestById: questById,
MissionIdsByQuestId: missionIdsByQuestId,
RouteIdByQuestId: routeIdByQuestId,
SceneIdsByQuestId: sceneIdsByQuestId,
OrderedQuestIds: orderedQuestIds,
FirstClearRewardsByGroupId: firstClearRewardsByGroupId,
FirstClearRewardSwitchesByQuestId: firstClearRewardSwitchesByQuestId,
MissionRewardsByMissionId: missionRewardsByMissionId,
WeaponIdsByReleaseConditionGroupId: weaponIdsByReleaseConditionGroupId,
ReleaseConditionsByGroupId: releaseConditionsByGroupId,
SceneGrantsBySceneId: sceneGrantsBySceneId,
BattleDropRewardById: battleDropRewardById,
PickupRewardIdsByGroupId: pickupRewardIdsByGroupId,
BattleDropsByQuestId: battleDropsByQuestId,
ReplayFlowRewardsByGroupId: replayFlowRewardsByGroupId,
RentalQuestIds: rentalQuestIds,
TutorialUnlockConditions: tutorialUnlockConds,
ChapterLastSceneByQuestId: chapterLastSceneByQuestId,
SeasonIdByRouteId: seasonIdByRouteId,
UserExpThresholds: BuildExpThresholds(paramMapRows, 1),
CharacterExpThresholds: BuildExpThresholds(paramMapRows, 31),
CostumeExpByRarity: costumeExpByRarity,
CostumeMaxLevelByRarity: costumeMaxLevelByRarity,
MaxStaminaByLevel: maxStaminaByLevel,
CostumeById: costumeById,
WeaponById: weaponById,
WeaponSkillSlots: skillSlots,
WeaponAbilitySlots: abilitySlots,
PartsCatalog: partsCatalog,
}, nil
}
+177
View File
@@ -0,0 +1,177 @@
package masterdata
import (
"fmt"
"sort"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/utils"
)
type ShopItemRow struct {
ShopItemId int32 `json:"ShopItemId"`
PriceType int32 `json:"PriceType"`
PriceId int32 `json:"PriceId"`
Price int32 `json:"Price"`
ShopItemLimitedStockId int32 `json:"ShopItemLimitedStockId"`
}
type ShopContentRow struct {
ShopItemId int32 `json:"ShopItemId"`
PossessionType int32 `json:"PossessionType"`
PossessionId int32 `json:"PossessionId"`
Count int32 `json:"Count"`
}
type ShopContentEffectRow struct {
ShopItemId int32 `json:"ShopItemId"`
EffectTargetType int32 `json:"EffectTargetType"`
EffectValueType int32 `json:"EffectValueType"`
EffectValue int32 `json:"EffectValue"`
}
type shopItemLimitedStockRow struct {
ShopItemLimitedStockId int32 `json:"ShopItemLimitedStockId"`
MaxCount int32 `json:"MaxCount"`
}
type shopRow struct {
ShopId int32 `json:"ShopId"`
ShopGroupType int32 `json:"ShopGroupType"`
ShopItemCellGroupId int32 `json:"ShopItemCellGroupId"`
}
type shopItemCellGroupRow struct {
ShopItemCellGroupId int32 `json:"ShopItemCellGroupId"`
ShopItemCellId int32 `json:"ShopItemCellId"`
SortOrder int32 `json:"SortOrder"`
}
type shopItemCellRow struct {
ShopItemCellId int32 `json:"ShopItemCellId"`
ShopItemId int32 `json:"ShopItemId"`
}
type ExchangeShopCell struct {
SortOrder int32
ShopItemId int32
}
type ShopCatalog struct {
Items map[int32]ShopItemRow
Contents map[int32][]ShopContentRow
Effects map[int32][]ShopContentEffectRow
MaxStaminaMillis map[int32]int32 // level -> max stamina in millis
LimitedStock map[int32]int32 // stock id -> max count
ItemShopPool []int32 // shop item IDs for the replaceable item shop, sorted by cell sort order
ExchangeShopCells map[int32][]ExchangeShopCell // shopId -> sorted cells for exchange shops
}
type userLevelEntry struct {
UserLevel int32 `json:"UserLevel"`
MaxStamina int32 `json:"MaxStamina"`
}
func LoadShopCatalog() (*ShopCatalog, error) {
items, err := utils.ReadJSON[ShopItemRow]("EntityMShopItemTable.json")
if err != nil {
return nil, fmt.Errorf("load shop item table: %w", err)
}
contents, err := utils.ReadJSON[ShopContentRow]("EntityMShopItemContentPossessionTable.json")
if err != nil {
return nil, fmt.Errorf("load shop content possession table: %w", err)
}
effects, err := utils.ReadJSON[ShopContentEffectRow]("EntityMShopItemContentEffectTable.json")
if err != nil {
return nil, fmt.Errorf("load shop content effect table: %w", err)
}
userLevels, err := utils.ReadJSON[userLevelEntry]("EntityMUserLevelTable.json")
if err != nil {
return nil, fmt.Errorf("load user level table: %w", err)
}
stockRows, err := utils.ReadJSON[shopItemLimitedStockRow]("EntityMShopItemLimitedStockTable.json")
if err != nil {
return nil, fmt.Errorf("load shop item limited stock table: %w", err)
}
catalog := &ShopCatalog{
Items: make(map[int32]ShopItemRow, len(items)),
Contents: make(map[int32][]ShopContentRow, len(contents)),
Effects: make(map[int32][]ShopContentEffectRow, len(effects)),
MaxStaminaMillis: make(map[int32]int32, len(userLevels)),
LimitedStock: make(map[int32]int32, len(stockRows)),
}
for _, row := range items {
catalog.Items[row.ShopItemId] = row
}
for _, row := range contents {
catalog.Contents[row.ShopItemId] = append(catalog.Contents[row.ShopItemId], row)
}
for _, row := range effects {
catalog.Effects[row.ShopItemId] = append(catalog.Effects[row.ShopItemId], row)
}
for _, ul := range userLevels {
catalog.MaxStaminaMillis[ul.UserLevel] = ul.MaxStamina * 1000
}
for _, row := range stockRows {
catalog.LimitedStock[row.ShopItemLimitedStockId] = row.MaxCount
}
shops, err := utils.ReadJSON[shopRow]("EntityMShopTable.json")
if err != nil {
return nil, fmt.Errorf("load shop table: %w", err)
}
cellGroups, err := utils.ReadJSON[shopItemCellGroupRow]("EntityMShopItemCellGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load shop item cell group table: %w", err)
}
cells, err := utils.ReadJSON[shopItemCellRow]("EntityMShopItemCellTable.json")
if err != nil {
return nil, fmt.Errorf("load shop item cell table: %w", err)
}
cellIdToItemId := make(map[int32]int32, len(cells))
for _, c := range cells {
cellIdToItemId[c.ShopItemCellId] = c.ShopItemId
}
cellGroupByCGId := make(map[int32][]shopItemCellGroupRow, len(cellGroups))
for _, cg := range cellGroups {
cellGroupByCGId[cg.ShopItemCellGroupId] = append(cellGroupByCGId[cg.ShopItemCellGroupId], cg)
}
catalog.ExchangeShopCells = make(map[int32][]ExchangeShopCell)
for _, s := range shops {
entries := cellGroupByCGId[s.ShopItemCellGroupId]
if len(entries) == 0 {
continue
}
switch s.ShopGroupType {
case model.ShopGroupTypeItemShop:
var poolCells []ExchangeShopCell
for _, cg := range entries {
if itemId, ok := cellIdToItemId[cg.ShopItemCellId]; ok {
poolCells = append(poolCells, ExchangeShopCell{cg.SortOrder, itemId})
}
}
sort.Slice(poolCells, func(i, j int) bool { return poolCells[i].SortOrder < poolCells[j].SortOrder })
catalog.ItemShopPool = make([]int32, len(poolCells))
for i, pc := range poolCells {
catalog.ItemShopPool[i] = pc.ShopItemId
}
case model.ShopGroupTypeExchangeShop:
var sc []ExchangeShopCell
for _, cg := range entries {
if itemId, ok := cellIdToItemId[cg.ShopItemCellId]; ok {
sc = append(sc, ExchangeShopCell{cg.SortOrder, itemId})
}
}
sort.Slice(sc, func(i, j int) bool { return sc[i].SortOrder < sc[j].SortOrder })
catalog.ExchangeShopCells[s.ShopId] = sc
}
}
return catalog, nil
}
+33
View File
@@ -0,0 +1,33 @@
package masterdata
import (
"log"
"lunar-tear/server/internal/utils"
)
type sideStorySceneRow struct {
SideStoryQuestId int32 `json:"SideStoryQuestId"`
SideStoryQuestSceneId int32 `json:"SideStoryQuestSceneId"`
SortOrder int32 `json:"SortOrder"`
}
type SideStoryCatalog struct {
FirstSceneByQuestId map[int32]int32
}
func LoadSideStoryCatalog() *SideStoryCatalog {
scenes, err := utils.ReadJSON[sideStorySceneRow]("EntityMSideStoryQuestSceneTable.json")
if err != nil {
log.Fatalf("load side story quest scene table: %v", err)
}
firstScene := make(map[int32]int32, len(scenes)/7)
for _, s := range scenes {
if s.SortOrder == 1 {
firstScene[s.SideStoryQuestId] = s.SideStoryQuestSceneId
}
}
log.Printf("side story catalog loaded: %d quests", len(firstScene))
return &SideStoryCatalog{FirstSceneByQuestId: firstScene}
}
+419
View File
@@ -0,0 +1,419 @@
package masterdata
import (
"fmt"
"log"
"sort"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/utils"
)
type WeaponMasterRow struct {
WeaponId int32 `json:"WeaponId"`
RarityType int32 `json:"RarityType"`
WeaponType int32 `json:"WeaponType"`
WeaponSpecificEnhanceId int32 `json:"WeaponSpecificEnhanceId"`
WeaponSkillGroupId int32 `json:"WeaponSkillGroupId"`
WeaponAbilityGroupId int32 `json:"WeaponAbilityGroupId"`
WeaponStoryReleaseConditionGroupId int32 `json:"WeaponStoryReleaseConditionGroupId"`
WeaponEvolutionMaterialGroupId int32 `json:"WeaponEvolutionMaterialGroupId"`
}
type WeaponStoryReleaseConditionRow struct {
WeaponStoryReleaseConditionGroupId int32 `json:"WeaponStoryReleaseConditionGroupId"`
StoryIndex int32 `json:"StoryIndex"`
WeaponStoryReleaseConditionType model.WeaponStoryReleaseConditionType `json:"WeaponStoryReleaseConditionType"`
ConditionValue int32 `json:"ConditionValue"`
WeaponStoryReleaseConditionOperationGroupId int32 `json:"WeaponStoryReleaseConditionOperationGroupId"`
}
type WeaponSkillGroupRow struct {
WeaponSkillGroupId int32 `json:"WeaponSkillGroupId"`
SlotNumber int32 `json:"SlotNumber"`
SkillId int32 `json:"SkillId"`
WeaponSkillEnhancementMaterialId int32 `json:"WeaponSkillEnhancementMaterialId"`
}
type WeaponAbilityGroupRow struct {
WeaponAbilityGroupId int32 `json:"WeaponAbilityGroupId"`
SlotNumber int32 `json:"SlotNumber"`
AbilityId int32 `json:"AbilityId"`
WeaponAbilityEnhancementMaterialId int32 `json:"WeaponAbilityEnhancementMaterialId"`
}
type weaponSpecificEnhanceRow struct {
WeaponSpecificEnhanceId int32 `json:"WeaponSpecificEnhanceId"`
BaseEnhancementObtainedExp int32 `json:"BaseEnhancementObtainedExp"`
SellPriceNumericalFunctionId int32 `json:"SellPriceNumericalFunctionId"`
RequiredExpForLevelUpNumericalParameterMapId int32 `json:"RequiredExpForLevelUpNumericalParameterMapId"`
EnhancementCostByWeaponNumericalFunctionId int32 `json:"EnhancementCostByWeaponNumericalFunctionId"`
EnhancementCostByMaterialNumericalFunctionId int32 `json:"EnhancementCostByMaterialNumericalFunctionId"`
MaxLevelNumericalFunctionId int32 `json:"MaxLevelNumericalFunctionId"`
EvolutionCostNumericalFunctionId int32 `json:"EvolutionCostNumericalFunctionId"`
LimitBreakCostByWeaponNumericalFunctionId int32 `json:"LimitBreakCostByWeaponNumericalFunctionId"`
LimitBreakCostByMaterialNumericalFunctionId int32 `json:"LimitBreakCostByMaterialNumericalFunctionId"`
MaxSkillLevelNumericalFunctionId int32 `json:"MaxSkillLevelNumericalFunctionId"`
SkillEnhancementCostNumericalFunctionId int32 `json:"SkillEnhancementCostNumericalFunctionId"`
MaxAbilityLevelNumericalFunctionId int32 `json:"MaxAbilityLevelNumericalFunctionId"`
AbilityEnhancementCostNumericalFunctionId int32 `json:"AbilityEnhancementCostNumericalFunctionId"`
}
type weaponConsumeExchangeRow struct {
WeaponId int32 `json:"WeaponId"`
ConsumableItemId int32 `json:"ConsumableItemId"`
Count int32 `json:"Count"`
}
type WeaponEvolutionGroupRow struct {
WeaponEvolutionGroupId int32 `json:"WeaponEvolutionGroupId"`
EvolutionOrder int32 `json:"EvolutionOrder"`
WeaponId int32 `json:"WeaponId"`
}
type WeaponEvolutionMaterialRow struct {
WeaponEvolutionMaterialGroupId int32 `json:"WeaponEvolutionMaterialGroupId"`
MaterialId int32 `json:"MaterialId"`
Count int32 `json:"Count"`
SortOrder int32 `json:"SortOrder"`
}
type WeaponSkillEnhanceMaterialRow struct {
WeaponSkillEnhancementMaterialId int32 `json:"WeaponSkillEnhancementMaterialId"`
SkillLevel int32 `json:"SkillLevel"`
MaterialId int32 `json:"MaterialId"`
Count int32 `json:"Count"`
SortOrder int32 `json:"SortOrder"`
}
type WeaponAbilityEnhanceMaterialRow struct {
WeaponAbilityEnhancementMaterialId int32 `json:"WeaponAbilityEnhancementMaterialId"`
AbilityLevel int32 `json:"AbilityLevel"`
MaterialId int32 `json:"MaterialId"`
Count int32 `json:"Count"`
SortOrder int32 `json:"SortOrder"`
}
type weaponRarityEnhanceRow struct {
RarityType int32 `json:"RarityType"`
BaseEnhancementObtainedExp int32 `json:"BaseEnhancementObtainedExp"`
SellPriceNumericalFunctionId int32 `json:"SellPriceNumericalFunctionId"`
RequiredExpForLevelUpNumericalParameterMapId int32 `json:"RequiredExpForLevelUpNumericalParameterMapId"`
EnhancementCostByWeaponNumericalFunctionId int32 `json:"EnhancementCostByWeaponNumericalFunctionId"`
EnhancementCostByMaterialNumericalFunctionId int32 `json:"EnhancementCostByMaterialNumericalFunctionId"`
MaxLevelNumericalFunctionId int32 `json:"MaxLevelNumericalFunctionId"`
EvolutionCostNumericalFunctionId int32 `json:"EvolutionCostNumericalFunctionId"`
LimitBreakCostByWeaponNumericalFunctionId int32 `json:"LimitBreakCostByWeaponNumericalFunctionId"`
LimitBreakCostByMaterialNumericalFunctionId int32 `json:"LimitBreakCostByMaterialNumericalFunctionId"`
MaxSkillLevelNumericalFunctionId int32 `json:"MaxSkillLevelNumericalFunctionId"`
SkillEnhancementCostNumericalFunctionId int32 `json:"SkillEnhancementCostNumericalFunctionId"`
MaxAbilityLevelNumericalFunctionId int32 `json:"MaxAbilityLevelNumericalFunctionId"`
AbilityEnhancementCostNumericalFunctionId int32 `json:"AbilityEnhancementCostNumericalFunctionId"`
}
type WeaponCatalog struct {
Weapons map[int32]WeaponMasterRow
Materials map[int32]MaterialRow
ExpByEnhanceId map[int32][]int32
GoldCostByEnhanceId map[int32]NumericalFunc
MaxLevelByEnhanceId map[int32]NumericalFunc
SellPriceByEnhanceId map[int32]NumericalFunc
MedalsByWeaponId map[int32]map[int32]int32 // WeaponId -> ConsumableItemId -> Count
EvolutionNextWeaponId map[int32]int32
EvolutionOrder map[int32]int32 // WeaponId -> 0-based position in evolution chain
EvolutionMaterials map[int32][]WeaponEvolutionMaterialRow // WeaponEvolutionMaterialGroupId -> materials
EvolutionCostByEnhanceId map[int32]NumericalFunc
AbilitySlots map[int32][]int32 // WeaponAbilityGroupId -> slot numbers
SkillGroupsByGroupId map[int32][]WeaponSkillGroupRow
SkillEnhanceMats map[[2]int32][]WeaponSkillEnhanceMaterialRow // key: [enhancementMaterialId, skillLevel]
SkillMaxLevelByEnhanceId map[int32]NumericalFunc
SkillCostByEnhanceId map[int32]NumericalFunc
AbilityGroupsByGroupId map[int32][]WeaponAbilityGroupRow
AbilityEnhanceMats map[[2]int32][]WeaponAbilityEnhanceMaterialRow // key: [enhancementMaterialId, abilityLevel]
AbilityMaxLevelByEnhanceId map[int32]NumericalFunc
AbilityCostByEnhanceId map[int32]NumericalFunc
EnhanceCostByWeaponByEnhanceId map[int32]NumericalFunc
LimitBreakCostByWeaponByEnhanceId map[int32]NumericalFunc
LimitBreakCostByMaterialByEnhanceId map[int32]NumericalFunc
BaseExpByEnhanceId map[int32]int32
ReleaseConditionsByGroupId map[int32][]WeaponStoryReleaseConditionRow
}
func LoadWeaponCatalog(matCatalog *MaterialCatalog) (*WeaponCatalog, error) {
weapons, err := utils.ReadJSON[WeaponMasterRow]("EntityMWeaponTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon table: %w", err)
}
enhanceRows, err := utils.ReadJSON[weaponSpecificEnhanceRow]("EntityMWeaponSpecificEnhanceTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon specific enhance table: %w", err)
}
rarityEnhanceRows, err := utils.ReadJSON[weaponRarityEnhanceRow]("EntityMWeaponRarityTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon rarity table: %w", err)
}
paramMapRows, err := LoadParameterMap()
if err != nil {
return nil, err
}
funcResolver, err := LoadFunctionResolver()
if err != nil {
return nil, fmt.Errorf("load function resolver: %w", err)
}
exchangeRows, err := utils.ReadJSON[weaponConsumeExchangeRow]("EntityMWeaponConsumeExchangeConsumableItemGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon consume exchange table: %w", err)
}
evoGroupRows, err := utils.ReadJSON[WeaponEvolutionGroupRow]("EntityMWeaponEvolutionGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon evolution group table: %w", err)
}
evoMatRows, err := utils.ReadJSON[WeaponEvolutionMaterialRow]("EntityMWeaponEvolutionMaterialGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon evolution material group table: %w", err)
}
abilityGroupRows, err := utils.ReadJSON[WeaponAbilityGroupRow]("EntityMWeaponAbilityGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon ability group table: %w", err)
}
skillGroupRows, err := utils.ReadJSON[WeaponSkillGroupRow]("EntityMWeaponSkillGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon skill group table: %w", err)
}
skillMatRows, err := utils.ReadJSON[WeaponSkillEnhanceMaterialRow]("EntityMWeaponSkillEnhancementMaterialTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon skill enhancement material table: %w", err)
}
abilityMatRows, err := utils.ReadJSON[WeaponAbilityEnhanceMaterialRow]("EntityMWeaponAbilityEnhancementMaterialTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon ability enhancement material table: %w", err)
}
releaseConditions, err := utils.ReadJSON[WeaponStoryReleaseConditionRow]("EntityMWeaponStoryReleaseConditionGroupTable.json")
if err != nil {
return nil, fmt.Errorf("load weapon story release condition table: %w", err)
}
catalog := &WeaponCatalog{
Weapons: make(map[int32]WeaponMasterRow, len(weapons)),
Materials: matCatalog.ByType[model.MaterialTypeWeaponEnhancement],
ExpByEnhanceId: make(map[int32][]int32, len(enhanceRows)),
GoldCostByEnhanceId: make(map[int32]NumericalFunc, len(enhanceRows)),
MaxLevelByEnhanceId: make(map[int32]NumericalFunc, len(enhanceRows)),
SellPriceByEnhanceId: make(map[int32]NumericalFunc, len(enhanceRows)),
MedalsByWeaponId: make(map[int32]map[int32]int32),
EvolutionNextWeaponId: make(map[int32]int32),
EvolutionOrder: make(map[int32]int32),
EvolutionMaterials: make(map[int32][]WeaponEvolutionMaterialRow),
EvolutionCostByEnhanceId: make(map[int32]NumericalFunc, len(enhanceRows)),
AbilitySlots: make(map[int32][]int32),
SkillGroupsByGroupId: make(map[int32][]WeaponSkillGroupRow),
SkillEnhanceMats: make(map[[2]int32][]WeaponSkillEnhanceMaterialRow),
SkillMaxLevelByEnhanceId: make(map[int32]NumericalFunc, len(enhanceRows)),
SkillCostByEnhanceId: make(map[int32]NumericalFunc, len(enhanceRows)),
AbilityGroupsByGroupId: make(map[int32][]WeaponAbilityGroupRow),
AbilityEnhanceMats: make(map[[2]int32][]WeaponAbilityEnhanceMaterialRow),
AbilityMaxLevelByEnhanceId: make(map[int32]NumericalFunc, len(enhanceRows)),
AbilityCostByEnhanceId: make(map[int32]NumericalFunc, len(enhanceRows)),
EnhanceCostByWeaponByEnhanceId: make(map[int32]NumericalFunc, len(enhanceRows)),
LimitBreakCostByWeaponByEnhanceId: make(map[int32]NumericalFunc, len(enhanceRows)),
LimitBreakCostByMaterialByEnhanceId: make(map[int32]NumericalFunc, len(enhanceRows)),
BaseExpByEnhanceId: make(map[int32]int32, len(enhanceRows)),
ReleaseConditionsByGroupId: make(map[int32][]WeaponStoryReleaseConditionRow),
}
for _, w := range weapons {
catalog.Weapons[w.WeaponId] = w
}
for _, r := range enhanceRows {
if _, ok := catalog.ExpByEnhanceId[r.WeaponSpecificEnhanceId]; !ok {
catalog.ExpByEnhanceId[r.WeaponSpecificEnhanceId] = BuildExpThresholds(paramMapRows, r.RequiredExpForLevelUpNumericalParameterMapId)
}
if _, ok := catalog.GoldCostByEnhanceId[r.WeaponSpecificEnhanceId]; !ok {
if f, found := funcResolver.Resolve(r.EnhancementCostByMaterialNumericalFunctionId); found {
catalog.GoldCostByEnhanceId[r.WeaponSpecificEnhanceId] = f
}
}
if _, ok := catalog.MaxLevelByEnhanceId[r.WeaponSpecificEnhanceId]; !ok {
if f, found := funcResolver.Resolve(r.MaxLevelNumericalFunctionId); found {
catalog.MaxLevelByEnhanceId[r.WeaponSpecificEnhanceId] = f
}
}
if _, ok := catalog.SellPriceByEnhanceId[r.WeaponSpecificEnhanceId]; !ok {
if f, found := funcResolver.Resolve(r.SellPriceNumericalFunctionId); found {
catalog.SellPriceByEnhanceId[r.WeaponSpecificEnhanceId] = f
}
}
if _, ok := catalog.EvolutionCostByEnhanceId[r.WeaponSpecificEnhanceId]; !ok {
if f, found := funcResolver.Resolve(r.EvolutionCostNumericalFunctionId); found {
catalog.EvolutionCostByEnhanceId[r.WeaponSpecificEnhanceId] = f
}
}
if _, ok := catalog.SkillMaxLevelByEnhanceId[r.WeaponSpecificEnhanceId]; !ok {
if f, found := funcResolver.Resolve(r.MaxSkillLevelNumericalFunctionId); found {
catalog.SkillMaxLevelByEnhanceId[r.WeaponSpecificEnhanceId] = f
}
}
if _, ok := catalog.SkillCostByEnhanceId[r.WeaponSpecificEnhanceId]; !ok {
if f, found := funcResolver.Resolve(r.SkillEnhancementCostNumericalFunctionId); found {
catalog.SkillCostByEnhanceId[r.WeaponSpecificEnhanceId] = f
}
}
if _, ok := catalog.AbilityMaxLevelByEnhanceId[r.WeaponSpecificEnhanceId]; !ok {
if f, found := funcResolver.Resolve(r.MaxAbilityLevelNumericalFunctionId); found {
catalog.AbilityMaxLevelByEnhanceId[r.WeaponSpecificEnhanceId] = f
}
}
if _, ok := catalog.AbilityCostByEnhanceId[r.WeaponSpecificEnhanceId]; !ok {
if f, found := funcResolver.Resolve(r.AbilityEnhancementCostNumericalFunctionId); found {
catalog.AbilityCostByEnhanceId[r.WeaponSpecificEnhanceId] = f
}
}
if _, ok := catalog.EnhanceCostByWeaponByEnhanceId[r.WeaponSpecificEnhanceId]; !ok {
if f, found := funcResolver.Resolve(r.EnhancementCostByWeaponNumericalFunctionId); found {
catalog.EnhanceCostByWeaponByEnhanceId[r.WeaponSpecificEnhanceId] = f
}
}
if _, ok := catalog.LimitBreakCostByWeaponByEnhanceId[r.WeaponSpecificEnhanceId]; !ok {
if f, found := funcResolver.Resolve(r.LimitBreakCostByWeaponNumericalFunctionId); found {
catalog.LimitBreakCostByWeaponByEnhanceId[r.WeaponSpecificEnhanceId] = f
}
}
if _, ok := catalog.LimitBreakCostByMaterialByEnhanceId[r.WeaponSpecificEnhanceId]; !ok {
if f, found := funcResolver.Resolve(r.LimitBreakCostByMaterialNumericalFunctionId); found {
catalog.LimitBreakCostByMaterialByEnhanceId[r.WeaponSpecificEnhanceId] = f
}
}
if _, ok := catalog.BaseExpByEnhanceId[r.WeaponSpecificEnhanceId]; !ok {
catalog.BaseExpByEnhanceId[r.WeaponSpecificEnhanceId] = r.BaseEnhancementObtainedExp
}
}
for _, ex := range exchangeRows {
if catalog.MedalsByWeaponId[ex.WeaponId] == nil {
catalog.MedalsByWeaponId[ex.WeaponId] = make(map[int32]int32)
}
catalog.MedalsByWeaponId[ex.WeaponId][ex.ConsumableItemId] = ex.Count
}
grouped := make(map[int32][]WeaponEvolutionGroupRow)
for _, row := range evoGroupRows {
grouped[row.WeaponEvolutionGroupId] = append(grouped[row.WeaponEvolutionGroupId], row)
}
for _, rows := range grouped {
sort.Slice(rows, func(i, j int) bool {
return rows[i].EvolutionOrder < rows[j].EvolutionOrder
})
for i, row := range rows {
catalog.EvolutionOrder[row.WeaponId] = int32(i)
if i < len(rows)-1 {
catalog.EvolutionNextWeaponId[row.WeaponId] = rows[i+1].WeaponId
}
}
}
for _, row := range evoMatRows {
catalog.EvolutionMaterials[row.WeaponEvolutionMaterialGroupId] = append(
catalog.EvolutionMaterials[row.WeaponEvolutionMaterialGroupId], row)
}
for _, row := range abilityGroupRows {
catalog.AbilitySlots[row.WeaponAbilityGroupId] = append(
catalog.AbilitySlots[row.WeaponAbilityGroupId], row.SlotNumber)
}
for _, row := range skillGroupRows {
catalog.SkillGroupsByGroupId[row.WeaponSkillGroupId] = append(
catalog.SkillGroupsByGroupId[row.WeaponSkillGroupId], row)
}
for _, row := range skillMatRows {
key := [2]int32{row.WeaponSkillEnhancementMaterialId, row.SkillLevel}
catalog.SkillEnhanceMats[key] = append(catalog.SkillEnhanceMats[key], row)
}
for _, row := range abilityGroupRows {
catalog.AbilityGroupsByGroupId[row.WeaponAbilityGroupId] = append(
catalog.AbilityGroupsByGroupId[row.WeaponAbilityGroupId], row)
}
for _, row := range abilityMatRows {
key := [2]int32{row.WeaponAbilityEnhancementMaterialId, row.AbilityLevel}
catalog.AbilityEnhanceMats[key] = append(catalog.AbilityEnhanceMats[key], row)
}
for _, c := range releaseConditions {
catalog.ReleaseConditionsByGroupId[c.WeaponStoryReleaseConditionGroupId] = append(
catalog.ReleaseConditionsByGroupId[c.WeaponStoryReleaseConditionGroupId], c)
}
// Rarity-based enhancement fallback: for weapons with WeaponSpecificEnhanceId == 0,
// use EntityMWeaponRarityTable curves via synthetic enhance IDs (-RarityType).
rarityByType := make(map[int32]weaponRarityEnhanceRow, len(rarityEnhanceRows))
for _, r := range rarityEnhanceRows {
rarityByType[r.RarityType] = r
}
registeredRarity := make(map[int32]bool, len(rarityEnhanceRows))
fallbackCount := 0
for wid, w := range catalog.Weapons {
if w.WeaponSpecificEnhanceId != 0 {
continue
}
syntheticId := -w.RarityType
if !registeredRarity[w.RarityType] {
r, ok := rarityByType[w.RarityType]
if !ok {
continue
}
catalog.ExpByEnhanceId[syntheticId] = BuildExpThresholds(paramMapRows, r.RequiredExpForLevelUpNumericalParameterMapId)
if f, found := funcResolver.Resolve(r.EnhancementCostByMaterialNumericalFunctionId); found {
catalog.GoldCostByEnhanceId[syntheticId] = f
}
if f, found := funcResolver.Resolve(r.MaxLevelNumericalFunctionId); found {
catalog.MaxLevelByEnhanceId[syntheticId] = f
}
if f, found := funcResolver.Resolve(r.SellPriceNumericalFunctionId); found {
catalog.SellPriceByEnhanceId[syntheticId] = f
}
if f, found := funcResolver.Resolve(r.EvolutionCostNumericalFunctionId); found {
catalog.EvolutionCostByEnhanceId[syntheticId] = f
}
if f, found := funcResolver.Resolve(r.MaxSkillLevelNumericalFunctionId); found {
catalog.SkillMaxLevelByEnhanceId[syntheticId] = f
}
if f, found := funcResolver.Resolve(r.SkillEnhancementCostNumericalFunctionId); found {
catalog.SkillCostByEnhanceId[syntheticId] = f
}
if f, found := funcResolver.Resolve(r.MaxAbilityLevelNumericalFunctionId); found {
catalog.AbilityMaxLevelByEnhanceId[syntheticId] = f
}
if f, found := funcResolver.Resolve(r.AbilityEnhancementCostNumericalFunctionId); found {
catalog.AbilityCostByEnhanceId[syntheticId] = f
}
if f, found := funcResolver.Resolve(r.EnhancementCostByWeaponNumericalFunctionId); found {
catalog.EnhanceCostByWeaponByEnhanceId[syntheticId] = f
}
if f, found := funcResolver.Resolve(r.LimitBreakCostByWeaponNumericalFunctionId); found {
catalog.LimitBreakCostByWeaponByEnhanceId[syntheticId] = f
}
if f, found := funcResolver.Resolve(r.LimitBreakCostByMaterialNumericalFunctionId); found {
catalog.LimitBreakCostByMaterialByEnhanceId[syntheticId] = f
}
catalog.BaseExpByEnhanceId[syntheticId] = r.BaseEnhancementObtainedExp
registeredRarity[w.RarityType] = true
}
w.WeaponSpecificEnhanceId = syntheticId
catalog.Weapons[wid] = w
fallbackCount++
}
log.Printf("[WeaponCatalog] rarity fallback: assigned synthetic enhance IDs to %d weapons", fallbackCount)
return catalog, nil
}
+45
View File
@@ -0,0 +1,45 @@
package model
type CharacterBoardEffectType int32
const (
CharacterBoardEffectTypeUnknown CharacterBoardEffectType = 0
CharacterBoardEffectTypeAbility CharacterBoardEffectType = 1
CharacterBoardEffectTypeStatusUp CharacterBoardEffectType = 2
)
type CharacterBoardStatusUpType int32
const (
CharacterBoardStatusUpTypeUnknown CharacterBoardStatusUpType = 0
CharacterBoardStatusUpTypeAgilityAdd CharacterBoardStatusUpType = 1
CharacterBoardStatusUpTypeAgilityMultiply CharacterBoardStatusUpType = 2
CharacterBoardStatusUpTypeAttackAdd CharacterBoardStatusUpType = 3
CharacterBoardStatusUpTypeAttackMultiply CharacterBoardStatusUpType = 4
CharacterBoardStatusUpTypeCritAttackAdd CharacterBoardStatusUpType = 5
CharacterBoardStatusUpTypeCritRatioAdd CharacterBoardStatusUpType = 6
CharacterBoardStatusUpTypeHpAdd CharacterBoardStatusUpType = 7
CharacterBoardStatusUpTypeHpMultiply CharacterBoardStatusUpType = 8
CharacterBoardStatusUpTypeVitalityAdd CharacterBoardStatusUpType = 9
CharacterBoardStatusUpTypeVitalityMultiply CharacterBoardStatusUpType = 10
)
type StatusCalculationType int32
const (
StatusCalculationTypeUnknown StatusCalculationType = 0
StatusCalculationTypeAdd StatusCalculationType = 1
StatusCalculationTypeMultiply StatusCalculationType = 2
)
func StatusUpTypeToCalcType(t CharacterBoardStatusUpType) StatusCalculationType {
switch t {
case CharacterBoardStatusUpTypeAgilityMultiply,
CharacterBoardStatusUpTypeAttackMultiply,
CharacterBoardStatusUpTypeHpMultiply,
CharacterBoardStatusUpTypeVitalityMultiply:
return StatusCalculationTypeMultiply
default:
return StatusCalculationTypeAdd
}
}
+13
View File
@@ -0,0 +1,13 @@
package model
type DeckType int32
const (
DeckTypeUnknown DeckType = 0
DeckTypeQuest DeckType = 1
DeckTypePvp DeckType = 2
DeckTypeMulti DeckType = 3
DeckTypeRestrictedQuest DeckType = 4
DeckTypeBigHunt DeckType = 5
DeckTypeRestrictedLimitContentQuest DeckType = 6
)
+13
View File
@@ -0,0 +1,13 @@
package model
const (
EffectTargetUnknown int32 = 0
EffectTargetStaminaRecovery int32 = 1
EffectTargetBattlePointRecovery int32 = 2
)
const (
EffectValueUnknown int32 = 0
EffectValueFixed int32 = 1
EffectValuePermil int32 = 2
)
+31
View File
@@ -0,0 +1,31 @@
package model
type EvaluateConditionFunctionType int32
const (
EvaluateConditionFunctionTypeUnknown EvaluateConditionFunctionType = 0
EvaluateConditionFunctionTypeRecursion EvaluateConditionFunctionType = 1
EvaluateConditionFunctionTypeCageTreasureHunt EvaluateConditionFunctionType = 2
EvaluateConditionFunctionTypeCageIntervalDropItem EvaluateConditionFunctionType = 3
EvaluateConditionFunctionTypeQuestClear EvaluateConditionFunctionType = 4
EvaluateConditionFunctionTypeGimmickBitCount EvaluateConditionFunctionType = 5
EvaluateConditionFunctionTypeWeaponAcquisition EvaluateConditionFunctionType = 6
EvaluateConditionFunctionTypeTutorial EvaluateConditionFunctionType = 7
EvaluateConditionFunctionTypeMissionClear EvaluateConditionFunctionType = 8
EvaluateConditionFunctionTypeQuestMissionClear EvaluateConditionFunctionType = 9
EvaluateConditionFunctionTypeOtherGimmickBitCount EvaluateConditionFunctionType = 10
EvaluateConditionFunctionTypeQuestSceneChoice EvaluateConditionFunctionType = 11
EvaluateConditionFunctionTypeQuestNotClear EvaluateConditionFunctionType = 12
)
type EvaluateConditionEvaluateType int32
const (
EvaluateConditionEvaluateTypeUnknown EvaluateConditionEvaluateType = 0
EvaluateConditionEvaluateTypeAnd EvaluateConditionEvaluateType = 1
EvaluateConditionEvaluateTypeOr EvaluateConditionEvaluateType = 2
EvaluateConditionEvaluateTypeGE EvaluateConditionEvaluateType = 3
EvaluateConditionEvaluateTypeIdContain EvaluateConditionEvaluateType = 4
EvaluateConditionEvaluateTypeEQ EvaluateConditionEvaluateType = 5
EvaluateConditionEvaluateTypeLE EvaluateConditionEvaluateType = 6
)
+121
View File
@@ -0,0 +1,121 @@
package model
const (
GachaLabelUnknown int32 = 0
GachaLabelPremium int32 = 1
GachaLabelEvent int32 = 2
GachaLabelChapter int32 = 3
GachaLabelPortalCage int32 = 4
GachaLabelRecycle int32 = 5
)
const (
GachaModeUnknown int32 = 0
GachaModeBasic int32 = 1
GachaModeStepup int32 = 2
GachaModeBox int32 = 3
)
const (
GachaUnlockUnknown int32 = 0
GachaUnlockNone int32 = 1
GachaUnlockUserRankGreaterOrEqual int32 = 2
GachaUnlockWithinHoursFromGameStart int32 = 3
GachaUnlockMainQuestClear int32 = 4
GachaUnlockMainFunctionReleased int32 = 5
)
const (
GachaAutoResetUnknown int32 = 0
GachaAutoResetNone int32 = 1
GachaAutoResetDaily int32 = 2
GachaAutoResetMonthly int32 = 3
)
const (
GachaDecorationUnknown int32 = 0
GachaDecorationNormal int32 = 1
GachaDecorationFestival int32 = 2
)
const (
GachaBadgeTypeNone int32 = 1
)
const (
PriceTypeUnknown int32 = 0
PriceTypeConsumableItem int32 = 1
PriceTypeGem int32 = 2
PriceTypePaidGem int32 = 3
PriceTypePlatformPayment int32 = 4
)
const (
BannerPrefixLimited = "limited_"
BannerPrefixStepUp = "step_up_"
BannerPrefixCommon = "common_"
)
func IsMaterialBanner(labelType int32) bool {
return labelType == GachaLabelChapter || labelType == GachaLabelRecycle || labelType == GachaLabelPortalCage
}
const MomBannerDomainGacha int32 = 1
const StepUpGroupDivisor int32 = 1000
const (
PityCeilingCount int32 = 200
MedalCountCap int32 = 9999
)
const (
PremiumSinglePullPrice int32 = 300
PremiumMultiPullPrice int32 = 3000
PremiumMultiPullCount int32 = 10
)
const (
StepUpStep1Cost int32 = 2000
StepUpStep3Cost int32 = 3000
StepUpStep5Cost int32 = 5000
StepUpFreeCost int32 = 0
)
const (
FeaturedRateUpPercent int = 35
FeaturedRateUpDenom int = 100
)
const (
StepUpRateBoost float64 = 1.5
StepUpRateMaxBoost float64 = 2.0
)
const (
DupGradeMin int32 = 2
DupGradeRange int = 4
)
type DupExchangeEntry struct {
PossessionType int32
PossessionId int32
Count int32
}
const DefaultDailyDrawLimit int32 = 5
const (
BoxPoolMaxItems int = 50
BoxPoolMinItems int = 5
BoxItemDefaultMax int32 = 10
BoxFallbackItemMax int32 = 20
BoxFallbackItemId int32 = 100001
)
const PhaseIdMultiplier int32 = 10
const (
ConsumableIdPremiumTicket int32 = 1
ConsumableIdChapterTicket int32 = 2
)
+21
View File
@@ -0,0 +1,21 @@
package model
type MaterialType int32
const (
MaterialTypeUnknown MaterialType = 0
MaterialTypeWeaponEnhancement MaterialType = 10
MaterialTypeCostumeEnhancement MaterialType = 20
MaterialTypeCompanionEnhancement MaterialType = 30
MaterialTypeWeaponSkillEnhancement MaterialType = 40
MaterialTypeCostumeSkillEnhancement MaterialType = 50
MaterialTypeCommonSkillEnhancement MaterialType = 60
MaterialTypeWeaponEvolution MaterialType = 70
MaterialTypeWeaponLimitBreak MaterialType = 80
MaterialTypeCostumeLimitBreak MaterialType = 90
MaterialTypeCharacterBoardRelease MaterialType = 100
MaterialTypeCostumeAwaken MaterialType = 110
MaterialTypeCharacterRebirth MaterialType = 120
MaterialTypeWeaponAwaken MaterialType = 130
MaterialTypeCostumeLotteryEffectUnlock MaterialType = 140
)
+14
View File
@@ -0,0 +1,14 @@
package model
type NumericalFunctionType int32
const (
NumericalFunctionTypeUnknown NumericalFunctionType = 0
NumericalFunctionTypeLinear NumericalFunctionType = 1
NumericalFunctionTypeMonomial NumericalFunctionType = 2
NumericalFunctionTypeDuplexLinear NumericalFunctionType = 3
NumericalFunctionTypeLinearPermil NumericalFunctionType = 4
NumericalFunctionTypePolynomialThird NumericalFunctionType = 5
NumericalFunctionTypePolynomialThirdPermil NumericalFunctionType = 6
NumericalFunctionTypePartsMainOption NumericalFunctionType = 7
)
+23
View File
@@ -0,0 +1,23 @@
package model
type PossessionType int32
const (
PossessionTypeUnknown PossessionType = 0
PossessionTypeCostume PossessionType = 1
PossessionTypeWeapon PossessionType = 2
PossessionTypeCompanion PossessionType = 3
PossessionTypeParts PossessionType = 4
PossessionTypeMaterial PossessionType = 5
PossessionTypeConsumableItem PossessionType = 6
PossessionTypeCostumeEnhanced PossessionType = 7
PossessionTypeWeaponEnhanced PossessionType = 8
PossessionTypeCompanionEnhanced PossessionType = 9
PossessionTypePartsEnhanced PossessionType = 10
PossessionTypePaidGem PossessionType = 11
PossessionTypeFreeGem PossessionType = 12
PossessionTypeImportantItem PossessionType = 13
PossessionTypeThought PossessionType = 14
PossessionTypeMissionPassPoint PossessionType = 15
PossessionTypePremiumItem PossessionType = 16
)
+160
View File
@@ -0,0 +1,160 @@
package model
import "fmt"
type QuestFlowType int32
const (
QuestFlowTypeUnknown QuestFlowType = 0
QuestFlowTypeMainFlow QuestFlowType = 1
QuestFlowTypeSubFlow QuestFlowType = 2
QuestFlowTypeReplayFlow QuestFlowType = 3
QuestFlowTypeAnotherRouteReplayFlow QuestFlowType = 4
)
func (t QuestFlowType) String() string {
switch t {
case QuestFlowTypeUnknown:
return "unknown"
case QuestFlowTypeMainFlow:
return "main-flow"
case QuestFlowTypeSubFlow:
return "sub-flow"
case QuestFlowTypeReplayFlow:
return "replay-flow"
case QuestFlowTypeAnotherRouteReplayFlow:
return "another-route-replay-flow"
default:
return fmt.Sprintf("unknown-quest-flow(%d)", int32(t))
}
}
type QuestResultType int32
const (
QuestResultTypeUnknown QuestResultType = 0
QuestResultTypeNone QuestResultType = 1
QuestResultTypeHalfResult QuestResultType = 2
QuestResultTypeFullResult QuestResultType = 3
)
type QuestSceneType int32
const (
QuestSceneTypeUnknown QuestSceneType = 0
QuestSceneTypeTower QuestSceneType = 1
QuestSceneTypePictureBook QuestSceneType = 2
QuestSceneTypeField QuestSceneType = 3
QuestSceneTypeNovel QuestSceneType = 4
QuestSceneTypeLimitContent QuestSceneType = 5
)
type QuestMissionConditionType int
const (
QuestMissionConditionTypeUnknown QuestMissionConditionType = 0
QuestMissionConditionTypeLessThanOrEqualXPeopleNotAlive QuestMissionConditionType = 1
QuestMissionConditionTypeMaxDamage QuestMissionConditionType = 2
QuestMissionConditionTypeSpecifiedCostumeIsInDeck QuestMissionConditionType = 3
QuestMissionConditionTypeSpecifiedCharacterIsInDeck QuestMissionConditionType = 4
QuestMissionConditionTypeSpecifiedAttributeMainWeaponIsInDeck QuestMissionConditionType = 5
QuestMissionConditionTypeGreaterThanOrEqualXCostumeSkillUseCount QuestMissionConditionType = 6
QuestMissionConditionTypeGreaterThanOrEqualXWeaponSkillUseCount QuestMissionConditionType = 7
QuestMissionConditionTypeGreaterThanOrEqualXCompanionSkillUseCount QuestMissionConditionType = 8
QuestMissionConditionTypeCostumeSkillfulWeaponAllCharacter QuestMissionConditionType = 9
QuestMissionConditionTypeCostumeSkillfulWeaponAnyCharacter QuestMissionConditionType = 10
QuestMissionConditionTypeCostumeRarityEqAllCharacter QuestMissionConditionType = 11
QuestMissionConditionTypeCostumeRarityGeAllCharacter QuestMissionConditionType = 12
QuestMissionConditionTypeCostumeRarityLeAllCharacter QuestMissionConditionType = 13
QuestMissionConditionTypeCostumeRarityEqAnyCharacter QuestMissionConditionType = 14
QuestMissionConditionTypeCostumeRarityGeAnyCharacter QuestMissionConditionType = 15
QuestMissionConditionTypeCostumeRarityLeAnyCharacter QuestMissionConditionType = 16
QuestMissionConditionTypeWeaponEvolutionGroupId QuestMissionConditionType = 17
QuestMissionConditionTypeSpecifiedAttributeWeaponIsInDeck QuestMissionConditionType = 18
QuestMissionConditionTypeSpecifiedAttributeMainWeaponAllCharacter QuestMissionConditionType = 19
QuestMissionConditionTypeSpecifiedAttributeWeaponAllCharacter QuestMissionConditionType = 20
QuestMissionConditionTypeWeaponManSkillfulWeaponAllCharacter QuestMissionConditionType = 21
QuestMissionConditionTypeWeaponSkillfulWeaponAllCharacter QuestMissionConditionType = 22
QuestMissionConditionTypeWeaponManSkillfulWeaponAnyCharacter QuestMissionConditionType = 23
QuestMissionConditionTypeWeaponSkillfulWeaponAnyCharacter QuestMissionConditionType = 24
QuestMissionConditionTypeWeaponRarityEqAllCharacter QuestMissionConditionType = 25
QuestMissionConditionTypeWeaponRarityGeAllCharacter QuestMissionConditionType = 26
QuestMissionConditionTypeWeaponRarityLeAllCharacter QuestMissionConditionType = 27
QuestMissionConditionTypeWeaponMainRarityEqAllCharacter QuestMissionConditionType = 28
QuestMissionConditionTypeWeaponMainRarityGeAllCharacter QuestMissionConditionType = 29
QuestMissionConditionTypeWeaponMainRarityLeAllCharacter QuestMissionConditionType = 30
QuestMissionConditionTypeWeaponRarityEqAnyCharacter QuestMissionConditionType = 31
QuestMissionConditionTypeWeaponRarityGeAnyCharacter QuestMissionConditionType = 32
QuestMissionConditionTypeWeaponRarityLeAnyCharacter QuestMissionConditionType = 33
QuestMissionConditionTypeWeaponMainRarityEqAnyCharacter QuestMissionConditionType = 34
QuestMissionConditionTypeWeaponMainRarityGeAnyCharacter QuestMissionConditionType = 35
QuestMissionConditionTypeWeaponMainRarityLeAnyCharacter QuestMissionConditionType = 36
QuestMissionConditionTypeCompanionId QuestMissionConditionType = 37
QuestMissionConditionTypeCompanionAttribute QuestMissionConditionType = 38
QuestMissionConditionTypeCompanionCategory QuestMissionConditionType = 39
QuestMissionConditionTypePartsId QuestMissionConditionType = 40
QuestMissionConditionTypePartsGroupId QuestMissionConditionType = 41
QuestMissionConditionTypePartsRarityEq QuestMissionConditionType = 42
QuestMissionConditionTypePartsRarityGe QuestMissionConditionType = 43
QuestMissionConditionTypePartsRarityLe QuestMissionConditionType = 44
QuestMissionConditionTypeDeckPowerGe QuestMissionConditionType = 45
QuestMissionConditionTypeDeckPowerLe QuestMissionConditionType = 46
QuestMissionConditionTypeDeckCostumeNumEq QuestMissionConditionType = 47
QuestMissionConditionTypeDeckCostumeNumGe QuestMissionConditionType = 48
QuestMissionConditionTypeDeckCostumeNumLe QuestMissionConditionType = 49
QuestMissionConditionTypeCriticalCountGe QuestMissionConditionType = 50
QuestMissionConditionTypeMinHpPercentageGe QuestMissionConditionType = 51
QuestMissionConditionTypeComboCountGe QuestMissionConditionType = 52
QuestMissionConditionTypeComboMaxDamageGe QuestMissionConditionType = 53
QuestMissionConditionTypeLessThanOrEqualXCostumeSkillUseCount QuestMissionConditionType = 54
QuestMissionConditionTypeLessThanOrEqualXWeaponSkillUseCount QuestMissionConditionType = 55
QuestMissionConditionTypeLessThanOrEqualXCompanionSkillUseCount QuestMissionConditionType = 56
QuestMissionConditionTypeWithoutRecoverySkill QuestMissionConditionType = 57
QuestMissionConditionTypeWithoutCostumeSkill QuestMissionConditionType = 58
QuestMissionConditionTypeWithoutWeaponSkill QuestMissionConditionType = 59
QuestMissionConditionTypeWithoutCompanionSkill QuestMissionConditionType = 60
QuestMissionConditionTypeCharacterContainAll QuestMissionConditionType = 61
QuestMissionConditionTypeCharacterContainAny QuestMissionConditionType = 62
QuestMissionConditionTypeCostumeContainAll QuestMissionConditionType = 63
QuestMissionConditionTypeCostumeContainAny QuestMissionConditionType = 64
QuestMissionConditionTypeCostumeSkillfulWeaponContainAll QuestMissionConditionType = 65
QuestMissionConditionTypeCostumeSkillfulWeaponContainAny QuestMissionConditionType = 66
QuestMissionConditionTypeAttributeMainWeaponContainAll QuestMissionConditionType = 67
QuestMissionConditionTypeAttributeMainWeaponContainAny QuestMissionConditionType = 68
QuestMissionConditionTypeAttributeWeaponContainAll QuestMissionConditionType = 69
QuestMissionConditionTypeAttributeWeaponContainAny QuestMissionConditionType = 70
QuestMissionConditionTypeWeaponManSkillfulWeaponContainAll QuestMissionConditionType = 71
QuestMissionConditionTypeWeaponManSkillfulWeaponContainAny QuestMissionConditionType = 72
QuestMissionConditionTypeWeaponSkillfulWeaponContainAll QuestMissionConditionType = 73
QuestMissionConditionTypeWeaponSkillfulWeaponContainAny QuestMissionConditionType = 74
QuestMissionConditionTypeComplete QuestMissionConditionType = 9999
)
type WeaponStoryReleaseConditionType int32
const (
WeaponStoryReleaseConditionTypeUnknown WeaponStoryReleaseConditionType = 0
WeaponStoryReleaseConditionTypeAcquisition WeaponStoryReleaseConditionType = 1
WeaponStoryReleaseConditionTypeReachSpecifiedLevel WeaponStoryReleaseConditionType = 2
WeaponStoryReleaseConditionTypeReachInitialMaxLevel WeaponStoryReleaseConditionType = 3
WeaponStoryReleaseConditionTypeReachOnceEvolvedMaxLevel WeaponStoryReleaseConditionType = 4
WeaponStoryReleaseConditionTypeReachSpecifiedEvolutionCount WeaponStoryReleaseConditionType = 5
WeaponStoryReleaseConditionTypeQuestClear WeaponStoryReleaseConditionType = 6
WeaponStoryReleaseConditionTypeMainFlowSceneProgress WeaponStoryReleaseConditionType = 7
)
type UserQuestStateType int32
const (
UserQuestStateTypeUnknown UserQuestStateType = 0
UserQuestStateTypeActive UserQuestStateType = 1
UserQuestStateTypeCleared UserQuestStateType = 2
)
type SideStoryQuestStateType int32
const (
SideStoryQuestStateUnknown SideStoryQuestStateType = 0
SideStoryQuestStateActive SideStoryQuestStateType = 1
SideStoryQuestStateCleared SideStoryQuestStateType = 2
)
+11
View File
@@ -0,0 +1,11 @@
package model
type RarityType = int32
const (
RarityNormal RarityType = 10
RarityRare RarityType = 20
RaritySRare RarityType = 30
RaritySSRare RarityType = 40
RarityLegend RarityType = 50
)
+9
View File
@@ -0,0 +1,9 @@
package model
const (
ShopGroupTypeUnknown int32 = 0
ShopGroupTypePremiumShop int32 = 1
ShopGroupTypeItemShop int32 = 3
ShopGroupTypeExchangeShop int32 = 4
ShopGroupTypeRecoveryShop int32 = 5
)
+23
View File
@@ -0,0 +1,23 @@
package model
type StatusKindType int32
const (
StatusKindTypeUnknown StatusKindType = 0
StatusKindTypeAgility StatusKindType = 1
StatusKindTypeAttack StatusKindType = 2
StatusKindTypeCriticalAttack StatusKindType = 3
StatusKindTypeCriticalRatio StatusKindType = 4
StatusKindTypeEvasionRatio StatusKindType = 5
StatusKindTypeHp StatusKindType = 6
StatusKindTypeVitality StatusKindType = 7
)
type CostumeAwakenEffectType int32
const (
CostumeAwakenEffectTypeUnknown CostumeAwakenEffectType = 0
CostumeAwakenEffectTypeStatusUp CostumeAwakenEffectType = 1
CostumeAwakenEffectTypeAbility CostumeAwakenEffectType = 2
CostumeAwakenEffectTypeItemAcquire CostumeAwakenEffectType = 3
)
+117
View File
@@ -0,0 +1,117 @@
package model
type TutorialType int32
const (
TutorialTypeUnknown TutorialType = 0
TutorialTypeGameStart TutorialType = 1
TutorialTypeMenuFirst TutorialType = 2
TutorialTypeMenuSecond TutorialType = 3
TutorialTypeBattleWeaponSkill TutorialType = 4
TutorialTypeBattleCostumeSkill TutorialType = 5
TutorialTypeBlackBird TutorialType = 6
TutorialTypeEnhance TutorialType = 7
TutorialTypeCompanion TutorialType = 8
TutorialTypeParts TutorialType = 9
TutorialTypeExplore TutorialType = 10
TutorialTypePvp TutorialType = 11
TutorialTypeMainQuestHard TutorialType = 12
TutorialTypeMainQuestVeryHard TutorialType = 13
TutorialTypeEventQuestFirst TutorialType = 14
TutorialTypeEventQuestCharacter TutorialType = 15
TutorialTypeEventQuestMarathon TutorialType = 16
TutorialTypeEventQuestHunt TutorialType = 17
TutorialTypeEventQuestDungeon TutorialType = 18
TutorialTypeEventQuestDayOfTheWeek TutorialType = 19
TutorialTypeEventQuestGuerrilla TutorialType = 20
TutorialTypeEndContents TutorialType = 21
TutorialTypeEndContentsQuest TutorialType = 22
TutorialTypeExploreGame1 TutorialType = 23
TutorialTypeExploreGame2 TutorialType = 24
TutorialTypePortalCage TutorialType = 25
TutorialTypePortalCageMainQuest TutorialType = 26
TutorialTypeCage TutorialType = 27
TutorialTypePortalCageDailyQuest TutorialType = 28
TutorialTypePortalCageDailyGacha TutorialType = 29
TutorialTypePortalCageDropItem TutorialType = 30
TutorialTypePortalCageReachedLastScene TutorialType = 31
TutorialTypePortalCageCharacter1 TutorialType = 32
TutorialTypePortalCageCharacter2 TutorialType = 33
TutorialTypePortalCageCharacter3 TutorialType = 34
TutorialTypePortalCageCharacter4 TutorialType = 35
TutorialTypePortalCageCharacter5 TutorialType = 36
TutorialTypeBlackBirdCharacter1 TutorialType = 37
TutorialTypeBlackBirdCharacter2 TutorialType = 38
TutorialTypeBlackBirdCharacter3 TutorialType = 39
TutorialTypeGohobi TutorialType = 40
TutorialTypeGohobiDrop TutorialType = 41
TutorialTypeBattleCancelEnemyCast1 TutorialType = 42
TutorialTypeBattleCancelEnemyCast2 TutorialType = 43
TutorialTypeLoseFirst TutorialType = 44
TutorialTypeRewardGacha TutorialType = 45
TutorialTypeBigWinBonusFirst TutorialType = 46
TutorialTypeBigHunt TutorialType = 47
TutorialTypeTripleDeck TutorialType = 48
TutorialTypeCharacterBoard TutorialType = 49
TutorialTypeCharacterBoardBasic TutorialType = 50
TutorialTypeCharacterBoardBigHunt TutorialType = 51
TutorialTypeWorldMap TutorialType = 52
TutorialTypeMapItemFull TutorialType = 53
TutorialTypeWorldMapBlackBird TutorialType = 54
TutorialTypeWorldMapTreasure TutorialType = 55
TutorialTypeBrokenObelisk TutorialType = 56
TutorialTypeLoseFirstAfterChapter TutorialType = 57
TutorialTypeReplayFlowSkip TutorialType = 58
TutorialTypeWorldMapOutgame TutorialType = 59
TutorialTypeBattleCertainKillSkill TutorialType = 60
TutorialTypeSmartPhoneFirst TutorialType = 101
TutorialTypePhotoFirst TutorialType = 102
TutorialTypeDailyGacha TutorialType = 103
TutorialTypePortalCageSeason TutorialType = 104
TutorialTypeQuestSkip TutorialType = 201
TutorialTypePortalCageChapter TutorialType = 202
TutorialTypeCharacterBoardUnlock TutorialType = 301
TutorialTypeBlackBirdSistersFirst TutorialType = 401
TutorialTypeCostumeLevelBonus TutorialType = 501
TutorialTypeWorldMapReport TutorialType = 601
TutorialTypeBossSpecialEffect TutorialType = 701
TutorialTypeEventQuestGuerrillaFree TutorialType = 801
TutorialTypeExploreHard TutorialType = 901
TutorialTypeCageMemory TutorialType = 1001
TutorialTypeDressupCostume TutorialType = 1101
TutorialTypeCostumeAwaken TutorialType = 1201
TutorialTypeThoughtOrganization TutorialType = 1202
TutorialTypeHideObelisk TutorialType = 1301
TutorialTypeLimitContent TutorialType = 1302
TutorialTypeFieldEffect TutorialType = 1303
TutorialTypeLimitContentCage TutorialType = 1304
TutorialTypeCharacterViewer TutorialType = 1305
TutorialTypeRecycleGacha TutorialType = 1306
TutorialTypeMomPoint TutorialType = 1401
TutorialTypeStainedGlass TutorialType = 1402
TutorialTypeCharacterRebirth TutorialType = 1501
TutorialTypeWeaponAwaken TutorialType = 1502
TutorialTypeEventQuestLabyrinth TutorialType = 1503
TutorialTypeProperAttribute TutorialType = 1601
TutorialTypeMissionPass TutorialType = 1701
TutorialTypeWeaponAllOrganization TutorialType = 1702
TutorialTypeCostumeLotteryEffect TutorialType = 1801
TutorialTypeAnotherRoute TutorialType = 2001
TutorialTypeDeleteCostumeFio TutorialType = 2101
)
type TutorialUnlockConditionType int32
const (
TutorialUnlockConditionTypeFunctionReleased TutorialUnlockConditionType = 1
TutorialUnlockConditionTypeReachSpecifiedQuestScene TutorialUnlockConditionType = 2
TutorialUnlockConditionTypeUntilReachSpecifiedScene TutorialUnlockConditionType = 3
)
// TutorialPhase values are runtime-initialized in the client (static readonly),
// so only observed values are listed here.
type TutorialPhase int32
const (
TutorialPhaseMomMenuEditDeck TutorialPhase = 20
)
@@ -0,0 +1,50 @@
package questflow
import (
"fmt"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
)
func (h *QuestHandler) HandleBigHuntQuestStart(user *store.UserState, questId, userDeckNumber int32, nowMillis int64) {
quest, ok := h.QuestById[questId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleBigHuntQuestStart", questId))
}
h.initQuestState(user, questId)
if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis)
}
questState := user.Quests[questId]
questState.UserDeckNumber = userDeckNumber
questState.QuestStateType = model.UserQuestStateTypeActive
questState.LatestStartDatetime = nowMillis
user.Quests[questId] = questState
}
func (h *QuestHandler) HandleBigHuntQuestFinish(user *store.UserState, questId int32, isRetired, isAnnihilated bool, nowMillis int64) FinishOutcome {
quest, ok := h.QuestById[questId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleBigHuntQuestFinish", questId))
}
outcome := h.evaluateFinishOutcome(user, questId)
if !isRetired {
h.applyQuestVictory(user, questId, outcome, nowMillis)
}
if isRetired && !isAnnihilated && quest.Stamina > 1 {
refund := quest.Stamina - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
}
h.clearQuestMissions(user, questId, nowMillis)
return outcome
}
+89
View File
@@ -0,0 +1,89 @@
package questflow
import (
"fmt"
"log"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
)
func (h *QuestHandler) HandleEventQuestStart(user *store.UserState, eventQuestChapterId, questId int32, isBattleOnly bool, userDeckNumber int32, nowMillis int64) {
quest, ok := h.QuestById[questId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleEventQuestStart", questId))
}
h.initQuestState(user, questId)
if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis)
}
questState := user.Quests[questId]
questState.IsBattleOnly = isBattleOnly
questState.UserDeckNumber = userDeckNumber
questState.QuestStateType = model.UserQuestStateTypeActive
questState.LatestStartDatetime = nowMillis
user.Quests[questId] = questState
user.EventQuest.CurrentEventQuestChapterId = eventQuestChapterId
user.EventQuest.CurrentQuestId = questId
if sceneIds := h.SceneIdsByQuestId[questId]; len(sceneIds) > 0 {
user.EventQuest.CurrentQuestSceneId = sceneIds[0]
user.EventQuest.HeadQuestSceneId = sceneIds[0]
}
}
func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestChapterId, questId int32, isRetired, isAnnihilated bool, nowMillis int64) FinishOutcome {
quest, ok := h.QuestById[questId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleEventQuestFinish", questId))
}
outcome := h.evaluateFinishOutcome(user, questId)
if !isRetired {
h.applyQuestVictory(user, questId, outcome, nowMillis)
}
if isRetired && !isAnnihilated && quest.Stamina > 1 {
refund := quest.Stamina - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
}
user.EventQuest.CurrentQuestId = 0
user.EventQuest.CurrentQuestSceneId = 0
user.EventQuest.HeadQuestSceneId = 0
h.clearQuestMissions(user, questId, nowMillis)
return outcome
}
func (h *QuestHandler) HandleEventQuestRestart(user *store.UserState, eventQuestChapterId, questId int32, nowMillis int64) {
h.HandleQuestRestart(user, questId, nowMillis)
user.EventQuest.CurrentEventQuestChapterId = eventQuestChapterId
user.EventQuest.CurrentQuestId = questId
}
func (h *QuestHandler) HandleEventQuestSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
scene, ok := h.SceneById[questSceneId]
if !ok {
log.Printf("[HandleEventQuestSceneProgress] unknown sceneId=%d, skipping", questSceneId)
return
}
user.EventQuest.CurrentQuestSceneId = questSceneId
if h.isSceneAhead(questSceneId, user.EventQuest.HeadQuestSceneId) {
user.EventQuest.HeadQuestSceneId = questSceneId
}
h.applySceneGrants(user, questSceneId, nowMillis)
if scene.QuestResultType == model.QuestResultTypeHalfResult {
h.clearQuestMissions(user, scene.QuestId, nowMillis)
}
}
+86
View File
@@ -0,0 +1,86 @@
package questflow
import (
"fmt"
"log"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
)
func (h *QuestHandler) HandleExtraQuestStart(user *store.UserState, questId, userDeckNumber int32, nowMillis int64) {
quest, ok := h.QuestById[questId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleExtraQuestStart", questId))
}
h.initQuestState(user, questId)
if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis)
}
questState := user.Quests[questId]
questState.UserDeckNumber = userDeckNumber
questState.QuestStateType = model.UserQuestStateTypeActive
questState.LatestStartDatetime = nowMillis
user.Quests[questId] = questState
user.ExtraQuest.CurrentQuestId = questId
if sceneIds := h.SceneIdsByQuestId[questId]; len(sceneIds) > 0 {
user.ExtraQuest.CurrentQuestSceneId = sceneIds[0]
user.ExtraQuest.HeadQuestSceneId = sceneIds[0]
}
}
func (h *QuestHandler) HandleExtraQuestFinish(user *store.UserState, questId int32, isRetired, isAnnihilated bool, nowMillis int64) FinishOutcome {
quest, ok := h.QuestById[questId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleExtraQuestFinish", questId))
}
outcome := h.evaluateFinishOutcome(user, questId)
if !isRetired {
h.applyQuestVictory(user, questId, outcome, nowMillis)
}
if isRetired && !isAnnihilated && quest.Stamina > 1 {
refund := quest.Stamina - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
}
user.ExtraQuest.CurrentQuestId = 0
user.ExtraQuest.CurrentQuestSceneId = 0
user.ExtraQuest.HeadQuestSceneId = 0
h.clearQuestMissions(user, questId, nowMillis)
return outcome
}
func (h *QuestHandler) HandleExtraQuestRestart(user *store.UserState, questId int32, nowMillis int64) {
h.HandleQuestRestart(user, questId, nowMillis)
user.ExtraQuest.CurrentQuestId = questId
}
func (h *QuestHandler) HandleExtraQuestSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
scene, ok := h.SceneById[questSceneId]
if !ok {
log.Printf("[HandleExtraQuestSceneProgress] unknown sceneId=%d, skipping", questSceneId)
return
}
user.ExtraQuest.CurrentQuestSceneId = questSceneId
if h.isSceneAhead(questSceneId, user.ExtraQuest.HeadQuestSceneId) {
user.ExtraQuest.HeadQuestSceneId = questSceneId
}
h.applySceneGrants(user, questSceneId, nowMillis)
if scene.QuestResultType == model.QuestResultTypeHalfResult {
h.clearQuestMissions(user, scene.QuestId, nowMillis)
}
}
+68
View File
@@ -0,0 +1,68 @@
package questflow
import (
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
)
type RewardGrant struct {
PossessionType model.PossessionType
PossessionId int32
Count int32
}
type FinishOutcome struct {
DropRewards []RewardGrant
FirstClearRewards []RewardGrant
ReplayFlowFirstClearRewards []RewardGrant
MissionClearRewards []RewardGrant
MissionClearCompleteRewards []RewardGrant
BigWinClearedQuestMissionIds []int32
IsBigWin bool
}
type QuestHandler struct {
*masterdata.QuestCatalog
Config *masterdata.GameConfig
Granter *store.PossessionGranter
}
func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig) *QuestHandler {
granter := BuildGranter(catalog)
return &QuestHandler{QuestCatalog: catalog, Config: config, Granter: granter}
}
func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
costumeById := make(map[int32]store.CostumeRef, len(catalog.CostumeById))
for id, cm := range catalog.CostumeById {
costumeById[id] = store.CostumeRef{CharacterId: cm.CharacterId}
}
weaponById := make(map[int32]store.WeaponRef, len(catalog.WeaponById))
for id, wm := range catalog.WeaponById {
weaponById[id] = store.WeaponRef{
WeaponSkillGroupId: wm.WeaponSkillGroupId,
WeaponAbilityGroupId: wm.WeaponAbilityGroupId,
WeaponStoryReleaseConditionGroupId: wm.WeaponStoryReleaseConditionGroupId,
}
}
releaseConditions := make(map[int32][]store.WeaponStoryReleaseCond, len(catalog.ReleaseConditionsByGroupId))
for groupId, rows := range catalog.ReleaseConditionsByGroupId {
conds := make([]store.WeaponStoryReleaseCond, len(rows))
for i, r := range rows {
conds[i] = store.WeaponStoryReleaseCond{
StoryIndex: r.StoryIndex,
WeaponStoryReleaseConditionType: r.WeaponStoryReleaseConditionType,
ConditionValue: r.ConditionValue,
}
}
releaseConditions[groupId] = conds
}
return &store.PossessionGranter{
CostumeById: costumeById,
WeaponById: weaponById,
WeaponSkillSlots: catalog.WeaponSkillSlots,
WeaponAbilitySlots: catalog.WeaponAbilitySlots,
ReleaseConditions: releaseConditions,
}
}
+242
View File
@@ -0,0 +1,242 @@
package questflow
import (
"fmt"
"log"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
)
func (h *QuestHandler) initQuestState(user *store.UserState, questId int32) {
quest := user.Quests[questId]
quest.QuestId = questId
user.Quests[questId] = quest
for _, missionId := range h.MissionIdsByQuestId[questId] {
key := store.QuestMissionKey{QuestId: questId, QuestMissionId: missionId}
mission := user.QuestMissions[key]
mission.QuestId = questId
mission.QuestMissionId = missionId
user.QuestMissions[key] = mission
}
}
func isMainQuestPlayable(quest masterdata.QuestRow) bool {
return !quest.IsRunInTheBackground && quest.IsCountedAsQuest
}
func (h *QuestHandler) clearQuestMissions(user *store.UserState, questId int32, nowMillis int64) {
for _, missionId := range h.MissionIdsByQuestId[questId] {
key := store.QuestMissionKey{QuestId: questId, QuestMissionId: missionId}
mission := user.QuestMissions[key]
mission.IsClear = true
mission.ProgressValue = 1
mission.LatestClearDatetime = nowMillis
user.QuestMissions[key] = mission
}
}
func (h *QuestHandler) HandleQuestStart(user *store.UserState, questId int32, isBattleOnly bool, userDeckNumber int32, nowMillis int64) {
h.handleQuestStartInternal(user, questId, isBattleOnly, userDeckNumber, false, nowMillis)
}
func (h *QuestHandler) HandleQuestStartReplay(user *store.UserState, questId int32, isBattleOnly bool, userDeckNumber int32, nowMillis int64) {
h.handleQuestStartInternal(user, questId, isBattleOnly, userDeckNumber, true, nowMillis)
}
func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId int32, isBattleOnly bool, userDeckNumber int32, isReplayFlow bool, nowMillis int64) {
quest, ok := h.QuestById[questId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleQuestStart", questId))
}
h.initQuestState(user, questId)
if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis)
}
questState := user.Quests[questId]
if questState.QuestStateType == model.UserQuestStateTypeCleared {
if isReplayFlow {
user.MainQuest.SavedCurrentQuestSceneId = user.MainQuest.CurrentQuestSceneId
user.MainQuest.SavedHeadQuestSceneId = user.MainQuest.HeadQuestSceneId
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeReplayFlow)
user.MainQuest.ReplayFlowCurrentQuestSceneId = 0
user.MainQuest.ReplayFlowHeadQuestSceneId = 0
user.MainQuest.LatestVersion = nowMillis
questState.QuestStateType = model.UserQuestStateTypeActive
questState.LatestStartDatetime = nowMillis
questState.IsBattleOnly = isBattleOnly
questState.UserDeckNumber = userDeckNumber
user.Quests[questId] = questState
log.Printf("[HandleQuestStart] replay flow started for quest %d, saved scene=%d head=%d",
questId, user.MainQuest.SavedCurrentQuestSceneId, user.MainQuest.SavedHeadQuestSceneId)
}
return
}
questState.IsBattleOnly = isBattleOnly
questState.UserDeckNumber = userDeckNumber
if isMainQuestPlayable(quest) {
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow)
questState.QuestStateType = model.UserQuestStateTypeActive
questState.LatestStartDatetime = nowMillis
} else {
questState.QuestStateType = model.UserQuestStateTypeCleared
questState.ClearCount = 1
questState.DailyClearCount = 1
questState.LastClearDatetime = nowMillis
if sceneIds := h.SceneIdsByQuestId[questId]; len(sceneIds) > 0 {
firstSceneId := sceneIds[0]
prevSceneId := user.MainQuest.CurrentQuestSceneId
user.MainQuest.CurrentQuestSceneId = firstSceneId
if h.isSceneAhead(firstSceneId, user.MainQuest.HeadQuestSceneId) {
user.MainQuest.HeadQuestSceneId = firstSceneId
}
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow)
lastSceneId := h.getChapterLastSceneId(questId)
user.MainQuest.IsReachedLastQuestScene = firstSceneId == lastSceneId
if routeId, ok := h.RouteIdByQuestId[questId]; ok {
if seasonId, ok := h.SeasonIdByRouteId[routeId]; ok {
user.MainQuest.MainQuestSeasonId = seasonId
}
}
log.Printf("[HandleQuestStart] background quest %d auto-cleared, scene %d -> %d", questId, prevSceneId, firstSceneId)
}
}
user.Quests[questId] = questState
}
func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, outcome FinishOutcome, nowMillis int64) {
questState := user.Quests[questId]
if !questState.IsRewardGranted {
h.applyQuestRewards(user, questId, nowMillis)
h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeHalfResult, nowMillis)
h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeFullResult, nowMillis)
questState.IsRewardGranted = true
}
for _, drop := range outcome.DropRewards {
h.applyRewardPossession(user, drop.PossessionType, drop.PossessionId, drop.Count, nowMillis)
}
for _, reward := range outcome.ReplayFlowFirstClearRewards {
h.applyRewardPossession(user, reward.PossessionType, reward.PossessionId, reward.Count, nowMillis)
}
questState.QuestStateType = model.UserQuestStateTypeCleared
questState.ClearCount++
questState.DailyClearCount++
questState.LastClearDatetime = nowMillis
user.Quests[questId] = questState
}
func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, isRetired, isAnnihilated bool, nowMillis int64) FinishOutcome {
quest, ok := h.QuestById[questId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleQuestFinish", questId))
}
outcome := h.evaluateFinishOutcome(user, questId)
if !isRetired {
h.applyQuestVictory(user, questId, outcome, nowMillis)
}
if isRetired && !isAnnihilated && quest.Stamina > 1 {
refund := quest.Stamina - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
}
wasReplay := user.MainQuest.CurrentQuestFlowType == int32(model.QuestFlowTypeReplayFlow)
user.MainQuest.ProgressQuestSceneId = 0
user.MainQuest.ProgressHeadQuestSceneId = 0
user.MainQuest.ProgressQuestFlowType = 0
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeUnknown)
if wasReplay {
if user.MainQuest.SavedCurrentQuestSceneId > 0 {
user.MainQuest.CurrentQuestSceneId = user.MainQuest.SavedCurrentQuestSceneId
}
if user.MainQuest.SavedHeadQuestSceneId > 0 {
user.MainQuest.HeadQuestSceneId = user.MainQuest.SavedHeadQuestSceneId
}
user.MainQuest.SavedCurrentQuestSceneId = 0
user.MainQuest.SavedHeadQuestSceneId = 0
user.MainQuest.ReplayFlowCurrentQuestSceneId = 0
user.MainQuest.ReplayFlowHeadQuestSceneId = 0
log.Printf("[HandleQuestFinish] replay flow ended for quest %d, restored scene=%d head=%d",
questId, user.MainQuest.CurrentQuestSceneId, user.MainQuest.HeadQuestSceneId)
}
h.clearQuestMissions(user, questId, nowMillis)
return outcome
}
func (h *QuestHandler) HandleQuestSkip(user *store.UserState, questId, skipCount int32, nowMillis int64) FinishOutcome {
questDef, ok := h.QuestById[questId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleQuestSkip", questId))
}
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, skipCount, maxMillis, nowMillis)
skipTicketId := h.Config.ConsumableItemIdForQuestSkipTicket
user.ConsumableItems[skipTicketId] -= skipCount
if user.ConsumableItems[skipTicketId] < 0 {
user.ConsumableItems[skipTicketId] = 0
}
var allDrops []RewardGrant
for range skipCount {
drops := h.computeDropRewards(questDef)
for _, drop := range drops {
h.applyRewardPossession(user, drop.PossessionType, drop.PossessionId, drop.Count, nowMillis)
}
allDrops = append(allDrops, drops...)
if questDef.Gold != 0 {
user.ConsumableItems[h.Config.ConsumableItemIdForGold] += questDef.Gold
}
h.applyExpRewards(user, questId, nowMillis)
}
questState := user.Quests[questId]
questState.ClearCount += skipCount
questState.DailyClearCount += skipCount
questState.LastClearDatetime = nowMillis
user.Quests[questId] = questState
log.Printf("[HandleQuestSkip] questId=%d skipCount=%d drops=%d gold=%d", questId, skipCount, len(allDrops), questDef.Gold*skipCount)
return FinishOutcome{DropRewards: allDrops}
}
func (h *QuestHandler) HandleQuestRestart(user *store.UserState, questId int32, nowMillis int64) {
questDef, ok := h.QuestById[questId]
if ok && isMainQuestPlayable(questDef) {
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow)
}
quest := user.Quests[questId]
quest.QuestId = questId
quest.QuestStateType = model.UserQuestStateTypeActive
quest.IsBattleOnly = false
quest.LatestStartDatetime = nowMillis
user.Quests[questId] = quest
for _, missionId := range h.MissionIdsByQuestId[questId] {
key := store.QuestMissionKey{QuestId: questId, QuestMissionId: missionId}
m := user.QuestMissions[key]
m.QuestId = questId
m.QuestMissionId = missionId
m.IsClear = false
m.ProgressValue = 0
m.LatestClearDatetime = 0
user.QuestMissions[key] = m
}
}
+394
View File
@@ -0,0 +1,394 @@
package questflow
import (
"fmt"
"log"
"lunar-tear/server/internal/gameutil"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
)
func (h *QuestHandler) isQuestCleared(user *store.UserState, questId int32) bool {
quest, ok := user.Quests[questId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for isQuestCleared", questId))
}
return quest.QuestStateType == model.UserQuestStateTypeCleared
}
func appendMissionRewards(dst []RewardGrant, src []masterdata.QuestMissionRewardRow) []RewardGrant {
for _, r := range src {
dst = append(dst, RewardGrant{
PossessionType: r.PossessionType,
PossessionId: r.PossessionId,
Count: r.Count,
})
}
return dst
}
func (h *QuestHandler) firstClearRewardGroupId(user *store.UserState, questDef masterdata.QuestRow) int32 {
rewardGroupId := questDef.QuestFirstClearRewardGroupId
for _, switchRow := range h.FirstClearRewardSwitchesByQuestId[questDef.QuestId] {
if h.isQuestCleared(user, switchRow.SwitchConditionClearQuestId) {
rewardGroupId = switchRow.QuestFirstClearRewardGroupId
break
}
}
return rewardGroupId
}
func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int32) FinishOutcome {
outcome := FinishOutcome{}
questState, ok := user.Quests[questId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for evaluateFinishOutcome", questId))
}
questDef, ok := h.QuestById[questId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for evaluateFinishOutcome", questId))
}
if !questState.IsRewardGranted {
rewardGroupId := h.firstClearRewardGroupId(user, questDef)
for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] {
outcome.FirstClearRewards = append(outcome.FirstClearRewards, RewardGrant{
PossessionType: reward.PossessionType,
PossessionId: reward.PossessionId,
Count: reward.Count,
})
}
}
if user.MainQuest.CurrentQuestFlowType == int32(model.QuestFlowTypeReplayFlow) && questDef.QuestReplayFlowRewardGroupId > 0 {
for _, reward := range h.ReplayFlowRewardsByGroupId[questDef.QuestReplayFlowRewardGroupId] {
outcome.ReplayFlowFirstClearRewards = append(outcome.ReplayFlowFirstClearRewards, RewardGrant{
PossessionType: reward.PossessionType,
PossessionId: reward.PossessionId,
Count: reward.Count,
})
}
}
pendingClearCount := 0
regularMissionCount := 0
for _, questMissionId := range h.MissionIdsByQuestId[questId] {
missionDef, ok := h.MissionById[questMissionId]
if !ok || missionDef.QuestMissionConditionType == model.QuestMissionConditionTypeComplete {
continue
}
regularMissionCount++
key := store.QuestMissionKey{QuestId: questId, QuestMissionId: questMissionId}
mission := user.QuestMissions[key]
if !mission.IsClear {
pendingClearCount++
outcome.MissionClearRewards = appendMissionRewards(
outcome.MissionClearRewards,
h.MissionRewardsByMissionId[missionDef.QuestMissionRewardId],
)
}
}
priorClearCount := regularMissionCount - pendingClearCount
// On our server every mission auto-clears, so priorClearCount + pendingClearCount
// always equals regularMissionCount. The two-variable form is kept to mirror the
// original game's intent where individual missions could fail their conditions.
allRegularWillClear := regularMissionCount > 0 && (priorClearCount+pendingClearCount) == regularMissionCount
if allRegularWillClear {
for _, questMissionId := range h.MissionIdsByQuestId[questId] {
missionDef, ok := h.MissionById[questMissionId]
if !ok || missionDef.QuestMissionConditionType != model.QuestMissionConditionTypeComplete {
continue
}
key := store.QuestMissionKey{QuestId: questId, QuestMissionId: questMissionId}
if !user.QuestMissions[key].IsClear {
outcome.MissionClearCompleteRewards = appendMissionRewards(
outcome.MissionClearCompleteRewards,
h.MissionRewardsByMissionId[missionDef.QuestMissionRewardId],
)
outcome.BigWinClearedQuestMissionIds = append(outcome.BigWinClearedQuestMissionIds, questMissionId)
}
}
outcome.IsBigWin = len(outcome.BigWinClearedQuestMissionIds) > 0
}
outcome.DropRewards = h.computeDropRewards(questDef)
return outcome
}
func (h *QuestHandler) computeDropRewards(questDef masterdata.QuestRow) []RewardGrant {
if questDef.QuestPickupRewardGroupId == 0 {
return nil
}
var drops []RewardGrant
for _, dropId := range h.PickupRewardIdsByGroupId[questDef.QuestPickupRewardGroupId] {
if bdr, ok := h.BattleDropRewardById[dropId]; ok {
drops = append(drops, RewardGrant{
PossessionType: bdr.PossessionType,
PossessionId: bdr.PossessionId,
Count: bdr.Count,
})
}
}
return drops
}
func (h *QuestHandler) applyExpRewards(user *store.UserState, questId int32, nowMillis int64) {
questDef, ok := h.QuestById[questId]
if !ok {
return
}
oldLevel := user.Status.Level
user.Status.Exp += questDef.UserExp
user.Status.Level, user.Status.Exp = gameutil.LevelAndCap(user.Status.Exp, h.UserExpThresholds)
log.Printf("[applyExpRewards] questId=%d user: +%d exp -> total=%d level=%d", questId, questDef.UserExp, user.Status.Exp, user.Status.Level)
if user.Status.Level > oldLevel {
if maxStamina, ok := h.MaxStaminaByLevel[user.Status.Level]; ok {
store.ReplenishStamina(user, maxStamina*1000, nowMillis)
}
}
if h.RentalQuestIds[questId] {
log.Printf("[applyExpRewards] questId=%d skipping character/costume exp (rental deck)", questId)
return
}
deckCostumeUuids, deckCharacterIds := h.resolveDeckUnits(user, questId)
if deckCostumeUuids == nil {
log.Printf("[applyExpRewards] questId=%d skipping character/costume exp (deck not resolved)", questId)
return
}
if questDef.CharacterExp != 0 {
for id := range deckCharacterIds {
row := user.Characters[id]
row.Exp += questDef.CharacterExp
row.Level, row.Exp = gameutil.LevelAndCap(row.Exp, h.CharacterExpThresholds)
user.Characters[id] = row
log.Printf("[applyExpRewards] questId=%d character=%d: +%d exp -> total=%d level=%d", questId, id, questDef.CharacterExp, row.Exp, row.Level)
}
}
if questDef.CostumeExp != 0 {
for key := range deckCostumeUuids {
row := user.Costumes[key]
cm, ok := h.CostumeById[row.CostumeId]
if !ok {
continue
}
if maxLevelFunc, hasMax := h.CostumeMaxLevelByRarity[cm.RarityType]; hasMax {
maxLevel := maxLevelFunc.Evaluate(row.LimitBreakCount)
if row.Level >= maxLevel {
log.Printf("[applyExpRewards] questId=%d costume=%d (key=%s): at max level %d, skipping", questId, row.CostumeId, key, row.Level)
continue
}
}
row.Exp += questDef.CostumeExp
if thresholds, ok := h.CostumeExpByRarity[cm.RarityType]; ok {
row.Level, row.Exp = gameutil.LevelAndCap(row.Exp, thresholds)
if maxLevelFunc, hasMax := h.CostumeMaxLevelByRarity[cm.RarityType]; hasMax {
maxLevel := maxLevelFunc.Evaluate(row.LimitBreakCount)
if row.Level > maxLevel && int(maxLevel) < len(thresholds) {
row.Level = maxLevel
row.Exp = thresholds[maxLevel]
}
}
}
user.Costumes[key] = row
log.Printf("[applyExpRewards] questId=%d costume=%d (key=%s): +%d exp -> total=%d level=%d", questId, row.CostumeId, key, questDef.CostumeExp, row.Exp, row.Level)
}
}
}
func (h *QuestHandler) resolveDeckUnits(user *store.UserState, questId int32) (costumeUuids map[string]bool, characterIds map[int32]bool) {
dn := user.Quests[questId].UserDeckNumber
if dn == 0 {
return nil, nil
}
deck, ok := user.Decks[store.DeckKey{DeckType: model.DeckTypeQuest, UserDeckNumber: dn}]
if !ok {
return nil, nil
}
costumeUuids = make(map[string]bool)
characterIds = make(map[int32]bool)
for _, dcUuid := range []string{deck.UserDeckCharacterUuid01, deck.UserDeckCharacterUuid02, deck.UserDeckCharacterUuid03} {
if dcUuid == "" {
continue
}
dc, ok := user.DeckCharacters[dcUuid]
if !ok || dc.UserCostumeUuid == "" {
continue
}
costumeUuids[dc.UserCostumeUuid] = true
if costume, ok := user.Costumes[dc.UserCostumeUuid]; ok {
if cm, ok := h.CostumeById[costume.CostumeId]; ok {
characterIds[cm.CharacterId] = true
}
}
}
if len(costumeUuids) == 0 {
return nil, nil
}
return costumeUuids, characterIds
}
func (h *QuestHandler) applyQuestRewards(user *store.UserState, questId int32, nowMillis int64) {
questDef, ok := h.QuestById[questId]
if !ok {
return
}
h.applyExpRewards(user, questId, nowMillis)
if questDef.Gold != 0 {
user.ConsumableItems[h.Config.ConsumableItemIdForGold] += questDef.Gold
log.Printf("[applyQuestRewards] questId=%d gold: +%d -> total=%d", questId, questDef.Gold, user.ConsumableItems[h.Config.ConsumableItemIdForGold])
}
rewardGroupId := h.firstClearRewardGroupId(user, questDef)
for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] {
h.applyRewardPossession(user, reward.PossessionType, reward.PossessionId, reward.Count, nowMillis)
}
}
func (h *QuestHandler) applyRewardPossession(user *store.UserState, possType model.PossessionType, possId, count int32, nowMillis int64) {
switch possType {
case model.PossessionTypeCompanion:
h.grantCompanion(user, possId, nowMillis)
case model.PossessionTypeParts:
h.grantParts(user, possId, nowMillis)
default:
h.Granter.GrantFull(user, possType, possId, count, nowMillis)
}
}
func (h *QuestHandler) grantCompanion(user *store.UserState, companionId int32, nowMillis int64) {
for _, row := range user.Companions {
if row.CompanionId == companionId {
return
}
}
key := fmt.Sprintf("reward-companion-%d", companionId)
user.Companions[key] = store.CompanionState{
UserCompanionUuid: key,
CompanionId: companionId,
Level: 1,
HeadupDisplayViewId: 1,
AcquisitionDatetime: nowMillis,
}
}
func (h *QuestHandler) grantParts(user *store.UserState, partsId int32, nowMillis int64) {
for _, row := range user.Parts {
if row.PartsId == partsId {
return
}
}
var mainStatId int32
if partsDef, ok := h.PartsById[partsId]; ok {
mainStatId = h.DefaultPartsStatusMainByLotteryGroup[partsDef.PartsStatusMainLotteryGroupId]
if _, exists := user.PartsGroupNotes[partsDef.PartsGroupId]; !exists {
user.PartsGroupNotes[partsDef.PartsGroupId] = store.PartsGroupNoteState{
PartsGroupId: partsDef.PartsGroupId,
FirstAcquisitionDatetime: nowMillis,
LatestVersion: nowMillis,
}
}
}
key := fmt.Sprintf("reward-parts-%d", partsId)
user.Parts[key] = store.PartsState{
UserPartsUuid: key,
PartsId: partsId,
Level: 1,
PartsStatusMainId: mainStatId,
AcquisitionDatetime: nowMillis,
}
}
func (h *QuestHandler) grantWeaponStoryUnlock(user *store.UserState, weaponId, storyIndex int32, nowMillis int64) {
store.GrantWeaponStoryUnlock(user, weaponId, storyIndex, nowMillis)
}
var tutorialCompanionChoices = map[int32]int32{
1: 2, // bear + fire (Cat=1, Attr=2)
2: 1, // bear + wind (Cat=1, Attr=6)
3: 7, // doll + fire (Cat=3, Attr=2)
4: 10, // doll + wind (Cat=3, Attr=6)
}
func (h *QuestHandler) ApplyTutorialReward(user *store.UserState, tutorialType model.TutorialType, choiceId int32, nowMillis int64) []RewardGrant {
switch tutorialType {
case model.TutorialTypeCompanion:
return h.applyCompanionTutorialReward(user, choiceId, nowMillis)
default:
return nil
}
}
func (h *QuestHandler) applyCompanionTutorialReward(user *store.UserState, choiceId int32, nowMillis int64) []RewardGrant {
companionId, ok := tutorialCompanionChoices[choiceId]
if !ok {
log.Printf("[QuestHandler] unknown companion tutorial choiceId=%d", choiceId)
return nil
}
h.grantCompanion(user, companionId, nowMillis)
return []RewardGrant{{
PossessionType: model.PossessionTypeCompanion,
PossessionId: companionId,
Count: 1,
}}
}
func (h *QuestHandler) BattleDropRewards(questId int32) []masterdata.BattleDropInfo {
return h.BattleDropsByQuestId[questId]
}
func (h *QuestHandler) grantWeaponStoryUnlocksForQuestScene(user *store.UserState, questId int32, resultType model.QuestResultType, nowMillis int64) {
if resultType == model.QuestResultTypeHalfResult {
questDef, ok := h.QuestById[questId]
if !ok {
return
}
rewardGroupId := h.firstClearRewardGroupId(user, questDef)
for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] {
if reward.PossessionType != model.PossessionTypeWeapon {
continue
}
weaponId := reward.PossessionId
weapon, ok := h.WeaponById[weaponId]
if !ok || weapon.WeaponStoryReleaseConditionGroupId == 0 {
continue
}
groupId := weapon.WeaponStoryReleaseConditionGroupId
for _, cond := range h.ReleaseConditionsByGroupId[groupId] {
if cond.WeaponStoryReleaseConditionType == model.WeaponStoryReleaseConditionTypeAcquisition && cond.ConditionValue == 0 {
h.grantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
}
}
}
return
}
if resultType == model.QuestResultTypeFullResult {
for groupId, conditions := range h.ReleaseConditionsByGroupId {
for _, cond := range conditions {
if cond.WeaponStoryReleaseConditionType == model.WeaponStoryReleaseConditionTypeQuestClear && cond.ConditionValue == questId {
for _, weaponId := range h.WeaponIdsByReleaseConditionGroupId[groupId] {
h.grantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
}
break
}
}
}
}
}
+145
View File
@@ -0,0 +1,145 @@
package questflow
import (
"fmt"
"log"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
)
func (h *QuestHandler) applySceneGrants(user *store.UserState, questSceneId int32, nowMillis int64) {
grants, ok := h.SceneGrantsBySceneId[questSceneId]
if !ok {
return
}
for _, g := range grants {
h.applyRewardPossession(user, g.PossessionType, g.PossessionId, g.Count, nowMillis)
}
}
func (h *QuestHandler) isSceneAhead(newSceneId, currentHeadId int32) bool {
if currentHeadId == 0 {
return true
}
return h.SceneById[newSceneId].SortOrder > h.SceneById[currentHeadId].SortOrder
}
func (h *QuestHandler) HandleMainFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
scene, ok := h.SceneById[questSceneId]
if !ok {
panic(fmt.Sprintf("unknown sceneId=%d for HandleMainFlowSceneProgress", questSceneId))
}
quest, ok := h.QuestById[scene.QuestId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleMainFlowSceneProgress", questSceneId))
}
user.MainQuest.CurrentQuestSceneId = questSceneId
if h.isSceneAhead(questSceneId, user.MainQuest.HeadQuestSceneId) {
user.MainQuest.HeadQuestSceneId = questSceneId
}
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow)
if user.SideStoryActiveProgress.CurrentSideStoryQuestId != 0 {
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
LatestVersion: nowMillis,
}
}
lastSceneId := h.getChapterLastSceneId(scene.QuestId)
user.MainQuest.IsReachedLastQuestScene = questSceneId == lastSceneId
routeId, ok := h.RouteIdByQuestId[quest.QuestId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleMainFlowSceneProgress setting currentMainQuestRouteId", quest.QuestId))
}
user.MainQuest.CurrentMainQuestRouteId = routeId
user.PortalCageStatus.IsCurrentProgress = false
user.PortalCageStatus.LatestVersion = nowMillis
h.applySceneGrants(user, questSceneId, nowMillis)
}
func (h *QuestHandler) advanceTutorialsForScene(user *store.UserState, sceneId int32) {
currentScene, ok := h.SceneById[sceneId]
if !ok {
log.Printf("[advanceTutorialsForScene] unknown sceneId=%d", sceneId)
return
}
for _, cond := range h.TutorialUnlockConditions {
condScene, ok := h.SceneById[cond.ConditionValue]
if !ok {
log.Printf("[advanceTutorialsForScene] unknown conditionValue=%d", cond.ConditionValue)
continue
}
if currentScene.SortOrder >= condScene.SortOrder {
if _, exists := user.Tutorials[cond.TutorialType]; !exists {
user.Tutorials[cond.TutorialType] = store.TutorialProgressState{
TutorialType: cond.TutorialType,
ProgressPhase: 99999,
}
}
}
}
}
func (h *QuestHandler) getLastMainFlowSceneId(questId int32) int32 {
sceneIds := h.SceneIdsByQuestId[questId]
if len(sceneIds) == 0 {
panic(fmt.Sprintf("no scenes found for questId=%d", questId))
}
return sceneIds[len(sceneIds)-1]
}
func (h *QuestHandler) getChapterLastSceneId(questId int32) int32 {
if id, ok := h.ChapterLastSceneByQuestId[questId]; ok {
return id
}
return h.getLastMainFlowSceneId(questId)
}
func (h *QuestHandler) HandleReplayFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
user.MainQuest.ReplayFlowCurrentQuestSceneId = questSceneId
if user.MainQuest.ReplayFlowHeadQuestSceneId == 0 || h.isSceneAhead(questSceneId, user.MainQuest.ReplayFlowHeadQuestSceneId) {
user.MainQuest.ReplayFlowHeadQuestSceneId = questSceneId
}
user.MainQuest.LatestVersion = nowMillis
log.Printf("[HandleReplayFlowSceneProgress] sceneId=%d replayHead=%d", questSceneId, user.MainQuest.ReplayFlowHeadQuestSceneId)
}
func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, questSceneId int32) {
scene, ok := h.SceneById[questSceneId]
if !ok {
panic(fmt.Sprintf("unknown sceneId=%d for HandleMainQuestSceneProgress", questSceneId))
}
quest, ok := h.QuestById[scene.QuestId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleMainQuestSceneProgress", questSceneId))
}
if isMainQuestPlayable(quest) {
if scene.QuestResultType == model.QuestResultTypeHalfResult {
nowMillis := gametime.NowMillis()
h.clearQuestMissions(user, quest.QuestId, nowMillis)
}
user.MainQuest.ProgressQuestSceneId = questSceneId
if h.isSceneAhead(questSceneId, user.MainQuest.ProgressHeadQuestSceneId) {
user.MainQuest.ProgressHeadQuestSceneId = questSceneId
}
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeSubFlow)
user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeSubFlow)
} else {
user.MainQuest.CurrentQuestSceneId = questSceneId
if h.isSceneAhead(questSceneId, user.MainQuest.HeadQuestSceneId) {
user.MainQuest.HeadQuestSceneId = questSceneId
}
lastSceneId := h.getChapterLastSceneId(quest.QuestId)
user.MainQuest.IsReachedLastQuestScene = questSceneId == lastSceneId
}
}
+99
View File
@@ -0,0 +1,99 @@
package service
import (
"log"
"net"
"sync"
"time"
)
type revisionTracker struct {
mu sync.RWMutex
activeByClient map[string]string
lastRevision string
}
type assetResolution struct {
ActiveRevision string
ListRevision string
ListSize int64
Candidates []assetCandidate
}
type assetResolver struct{}
func newRevisionTracker() *revisionTracker {
return &revisionTracker{
activeByClient: make(map[string]string),
}
}
func newAssetResolver() *assetResolver {
return &assetResolver{}
}
func normalizeClientAddr(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err == nil && host != "" {
return host
}
return remoteAddr
}
func (t *revisionTracker) Remember(clientAddr, revision string) {
if revision == "" {
return
}
client := normalizeClientAddr(clientAddr)
t.mu.Lock()
if client != "" {
t.activeByClient[client] = revision
}
t.lastRevision = revision
t.mu.Unlock()
log.Printf("[Octo] Active list revision for client=%s set to %s", client, revision)
}
func (t *revisionTracker) Active(clientAddr string) string {
client := normalizeClientAddr(clientAddr)
t.mu.RLock()
revision := t.activeByClient[client]
if revision == "" {
revision = t.lastRevision
}
t.mu.RUnlock()
if revision == "" {
return "0"
}
return revision
}
func (r *assetResolver) Resolve(objectId, assetType, activeRevision string) (assetResolution, bool) {
start := time.Now()
resolution := assetResolution{ActiveRevision: activeRevision}
revision := activeRevision
candidates, listSize, ok := objectIdToFilePathCandidates(revision, assetType, objectId)
if ok && len(candidates) > 0 {
resolution.ListRevision = revision
resolution.ListSize = listSize
resolution.Candidates = candidates
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
log.Printf("[HTTP] Asset resolve slow: object_id=%s type=%s active_revision=%s list_revision=%s elapsed=%s", objectId, assetType, activeRevision, revision, elapsed)
}
return resolution, true
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
log.Printf("[HTTP] Asset resolve miss: object_id=%s type=%s active_revision=%s elapsed=%s", objectId, assetType, activeRevision, elapsed)
}
return resolution, false
}
func (r *assetResolver) Prewarm(activeRevision string) {
if activeRevision == "" {
return
}
_, _ = loadListBinIndex(activeRevision)
_ = loadInfoIndex(activeRevision)
}
+49
View File
@@ -0,0 +1,49 @@
package service
import (
"context"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type BannerServiceServer struct {
pb.UnimplementedBannerServiceServer
gacha store.GachaRepository
}
func NewBannerServiceServer(gacha store.GachaRepository) *BannerServiceServer {
return &BannerServiceServer{gacha: gacha}
}
func (s *BannerServiceServer) GetMamaBanner(ctx context.Context, req *pb.GetMamaBannerRequest) (*pb.GetMamaBannerResponse, error) {
catalog, _ := s.gacha.SnapshotCatalog()
var termLimited []*pb.GachaBanner
var latestChapter *pb.GachaBanner
for _, entry := range catalog {
if entry.GachaLabelType == model.GachaLabelPortalCage || entry.GachaLabelType == model.GachaLabelRecycle {
continue
}
b := &pb.GachaBanner{
GachaLabelType: entry.GachaLabelType,
GachaAssetName: entry.BannerAssetName,
GachaId: entry.GachaId,
}
switch entry.GachaLabelType {
case model.GachaLabelEvent, model.GachaLabelPremium:
termLimited = append(termLimited, b)
case model.GachaLabelChapter:
if latestChapter == nil || entry.GachaId > latestChapter.GachaId {
latestChapter = b
}
}
}
return &pb.GetMamaBannerResponse{
TermLimitedGacha: termLimited,
LatestChapterGacha: latestChapter,
IsExistUnreadPop: false,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
+54
View File
@@ -0,0 +1,54 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type BattleServiceServer struct {
pb.UnimplementedBattleServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewBattleServiceServer(users store.UserRepository, sessions store.SessionRepository) *BattleServiceServer {
return &BattleServiceServer{users: users, sessions: sessions}
}
func (s *BattleServiceServer) StartWave(ctx context.Context, req *pb.StartWaveRequest) (*pb.StartWaveResponse, error) {
log.Printf("[BattleService] StartWave: userParty=%d npcParty=%d", len(req.UserPartyInitialInfoList), len(req.NpcPartyInitialInfoList))
userId := currentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) {
user.Battle.IsActive = true
user.Battle.StartCount++
user.Battle.LastStartedAt = gametime.NowMillis()
user.Battle.LastUserPartyCount = int32(len(req.UserPartyInitialInfoList))
user.Battle.LastNpcPartyCount = int32(len(req.NpcPartyInitialInfoList))
})
return &pb.StartWaveResponse{
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *BattleServiceServer) FinishWave(ctx context.Context, req *pb.FinishWaveRequest) (*pb.FinishWaveResponse, error) {
log.Printf("[BattleService] FinishWave: battleBinary=%d userParty=%d npcParty=%d elapsedFrames=%d",
len(req.BattleBinary), len(req.UserPartyResultInfoList), len(req.NpcPartyResultInfoList), req.ElapsedFrameCount)
userId := currentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) {
user.Battle.IsActive = false
user.Battle.FinishCount++
user.Battle.LastFinishedAt = gametime.NowMillis()
user.Battle.LastUserPartyCount = int32(len(req.UserPartyResultInfoList))
user.Battle.LastNpcPartyCount = int32(len(req.NpcPartyResultInfoList))
user.Battle.LastBattleBinarySize = int32(len(req.BattleBinary))
user.Battle.LastElapsedFrameCount = req.ElapsedFrameCount
})
return &pb.FinishWaveResponse{
DiffUserData: userdata.EmptyDiff(),
}, nil
}
+92
View File
@@ -0,0 +1,92 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type CageOrnamentServiceServer struct {
pb.UnimplementedCageOrnamentServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CageOrnamentCatalog
granter *store.PossessionGranter
}
func NewCageOrnamentServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CageOrnamentCatalog, granter *store.PossessionGranter) *CageOrnamentServiceServer {
return &CageOrnamentServiceServer{users: users, sessions: sessions, catalog: catalog, granter: granter}
}
func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.ReceiveRewardRequest) (*pb.ReceiveRewardResponse, error) {
log.Printf("[CageOrnamentService] ReceiveReward: cageOrnamentId=%d", req.CageOrnamentId)
reward, ok := s.catalog.LookupReward(req.CageOrnamentId)
if !ok {
log.Fatalf("[CageOrnamentService] ReceiveReward: no reward for cageOrnamentId=%d", req.CageOrnamentId)
}
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.CageOrnamentRewards[req.CageOrnamentId] = store.CageOrnamentRewardState{
CageOrnamentId: req.CageOrnamentId,
AcquisitionDatetime: nowMillis,
LatestVersion: nowMillis,
}
s.granter.GrantFull(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis)
})
diff := userdata.BuildDiffFromTables(userdata.SelectTables(
userdata.FullClientTableMap(user),
[]string{
"IUserMaterial", "IUserConsumableItem", "IUserGem",
"IUserCostume", "IUserCostumeActiveSkill", "IUserCharacter",
"IUserWeapon", "IUserWeaponSkill", "IUserWeaponAbility",
"IUserWeaponNote", "IUserWeaponStory",
"IUserCageOrnamentReward",
},
))
return &pb.ReceiveRewardResponse{
CageOrnamentReward: []*pb.CageOrnamentReward{
{
PossessionType: reward.PossessionType,
PossessionId: reward.PossessionId,
Count: reward.Count,
},
},
DiffUserData: diff,
}, nil
}
func (s *CageOrnamentServiceServer) RecordAccess(ctx context.Context, req *pb.RecordAccessRequest) (*pb.RecordAccessResponse, error) {
log.Printf("[CageOrnamentService] RecordAccess: cageOrnamentId=%d", req.CageOrnamentId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
if _, exists := user.CageOrnamentRewards[req.CageOrnamentId]; !exists {
user.CageOrnamentRewards[req.CageOrnamentId] = store.CageOrnamentRewardState{
CageOrnamentId: req.CageOrnamentId,
AcquisitionDatetime: nowMillis,
LatestVersion: nowMillis,
}
}
})
diff := userdata.BuildDiffFromTables(userdata.SelectTables(
userdata.FullClientTableMap(user),
[]string{"IUserCageOrnamentReward"},
))
return &pb.RecordAccessResponse{
DiffUserData: diff,
}, nil
}
+85
View File
@@ -0,0 +1,85 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type CharacterServiceServer struct {
pb.UnimplementedCharacterServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CharacterRebirthCatalog
config *masterdata.GameConfig
}
func NewCharacterServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterRebirthCatalog, config *masterdata.GameConfig) *CharacterServiceServer {
return &CharacterServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
}
func (s *CharacterServiceServer) Rebirth(ctx context.Context, req *pb.RebirthRequest) (*pb.RebirthResponse, error) {
log.Printf("[CharacterService] Rebirth: characterId=%d rebirthCount=%d", req.CharacterId, req.RebirthCount)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
stepGroupId, ok := s.catalog.StepGroupByCharacterId[req.CharacterId]
if !ok {
log.Printf("[CharacterService] Rebirth: no step group for characterId=%d", req.CharacterId)
return &pb.RebirthResponse{}, nil
}
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"})
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
current := user.CharacterRebirths[req.CharacterId]
currentCount := current.RebirthCount
targetCount := currentCount + req.RebirthCount
for count := currentCount; count < targetCount; count++ {
step, ok := s.catalog.StepByGroupAndCount[masterdata.StepKey{GroupId: stepGroupId, BeforeRebirthCount: count}]
if !ok {
log.Printf("[CharacterService] Rebirth: no step row for groupId=%d beforeCount=%d", stepGroupId, count)
return
}
goldId := s.config.ConsumableItemIdForGold
user.ConsumableItems[goldId] = max(user.ConsumableItems[goldId]-s.config.CharacterRebirthConsumeGold, 0)
log.Printf("[CharacterService] Rebirth: consumed gold=%d", s.config.CharacterRebirthConsumeGold)
materials := s.catalog.MaterialsByGroupId[step.CharacterRebirthMaterialGroupId]
for _, mat := range materials {
user.Materials[mat.MaterialId] -= mat.Count
if user.Materials[mat.MaterialId] <= 0 {
delete(user.Materials, mat.MaterialId)
}
log.Printf("[CharacterService] Rebirth: consumed material=%d count=%d", mat.MaterialId, mat.Count)
}
}
log.Printf("[CharacterService] Rebirth: characterId=%d count %d -> %d", req.CharacterId, currentCount, targetCount)
user.CharacterRebirths[req.CharacterId] = store.CharacterRebirthState{
CharacterId: req.CharacterId,
RebirthCount: targetCount,
LatestVersion: nowMillis,
}
})
if err != nil {
log.Printf("[CharacterService] Rebirth error: %v", err)
return nil, err
}
rebirthTables := []string{"IUserCharacterRebirth", "IUserMaterial", "IUserConsumableItem"}
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), rebirthTables)
diff := tracker.Apply(snapshot, tables)
return &pb.RebirthResponse{DiffUserData: diff}, nil
}
+180
View File
@@ -0,0 +1,180 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type CharacterBoardServiceServer struct {
pb.UnimplementedCharacterBoardServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CharacterBoardCatalog
}
func NewCharacterBoardServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterBoardCatalog) *CharacterBoardServiceServer {
return &CharacterBoardServiceServer{users: users, sessions: sessions, catalog: catalog}
}
func (s *CharacterBoardServiceServer) ReleasePanel(ctx context.Context, req *pb.ReleasePanelRequest) (*pb.ReleasePanelResponse, error) {
log.Printf("[CharacterBoardService] ReleasePanel: panelIds=%v", req.CharacterBoardPanelId)
userId := currentUserId(ctx, s.users, s.sessions)
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"}).
Track("IUserConsumableItem", oldUser, userdata.SortedConsumableItemRecords, []string{"userId", "consumableItemId"})
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, panelId := range req.CharacterBoardPanelId {
panel, ok := s.catalog.PanelById[panelId]
if !ok {
log.Printf("[CharacterBoardService] unknown panelId=%d, skipping", panelId)
continue
}
s.consumeCosts(user, panel)
s.setReleaseBit(user, panel)
s.applyEffects(user, panel)
}
})
boardTables := []string{
"IUserCharacterBoard",
"IUserCharacterBoardAbility",
"IUserCharacterBoardStatusUp",
"IUserMaterial",
"IUserConsumableItem",
"IUserGem",
}
tables := userdata.SelectTables(userdata.FullClientTableMap(user), boardTables)
diff := tracker.Apply(user, tables)
return &pb.ReleasePanelResponse{DiffUserData: diff}, nil
}
func (s *CharacterBoardServiceServer) consumeCosts(user *store.UserState, panel masterdata.CharacterBoardPanelRow) {
costs := s.catalog.ReleaseCostsByGroupId[panel.CharacterBoardPanelReleasePossessionGroupId]
for _, cost := range costs {
store.DeductPossession(user, model.PossessionType(cost.PossessionType), cost.PossessionId, cost.Count)
}
}
func (s *CharacterBoardServiceServer) setReleaseBit(user *store.UserState, panel masterdata.CharacterBoardPanelRow) {
boardId := panel.CharacterBoardId
board := user.CharacterBoards[boardId]
board.CharacterBoardId = boardId
bitFieldIndex := (panel.SortOrder - 1) / 32
bitPosition := (panel.SortOrder - 1) % 32
mask := int32(1 << uint(bitPosition))
switch bitFieldIndex {
case 0:
board.PanelReleaseBit1 |= mask
case 1:
board.PanelReleaseBit2 |= mask
case 2:
board.PanelReleaseBit3 |= mask
case 3:
board.PanelReleaseBit4 |= mask
}
user.CharacterBoards[boardId] = board
}
func (s *CharacterBoardServiceServer) applyEffects(user *store.UserState, panel masterdata.CharacterBoardPanelRow) {
effects := s.catalog.ReleaseEffectsByGroupId[panel.CharacterBoardPanelReleaseEffectGroupId]
for _, eff := range effects {
switch model.CharacterBoardEffectType(eff.CharacterBoardEffectType) {
case model.CharacterBoardEffectTypeAbility:
s.applyAbilityEffect(user, eff)
case model.CharacterBoardEffectTypeStatusUp:
s.applyStatusUpEffect(user, eff)
}
}
}
func (s *CharacterBoardServiceServer) applyAbilityEffect(user *store.UserState, eff masterdata.CharacterBoardReleaseEffectRow) {
ability, ok := s.catalog.AbilityById[eff.CharacterBoardEffectId]
if !ok {
log.Printf("[CharacterBoardService] unknown abilityId=%d", eff.CharacterBoardEffectId)
return
}
characterId := s.resolveCharacterId(ability.CharacterBoardEffectTargetGroupId)
if characterId == 0 {
return
}
key := store.CharacterBoardAbilityKey{CharacterId: characterId, AbilityId: ability.AbilityId}
state := user.CharacterBoardAbilities[key]
state.CharacterId = characterId
state.AbilityId = ability.AbilityId
state.Level += eff.EffectValue
if maxLvl, ok := s.catalog.AbilityMaxLevel[key]; ok && state.Level > maxLvl {
state.Level = maxLvl
}
user.CharacterBoardAbilities[key] = state
}
func (s *CharacterBoardServiceServer) applyStatusUpEffect(user *store.UserState, eff masterdata.CharacterBoardReleaseEffectRow) {
statusUp, ok := s.catalog.StatusUpById[eff.CharacterBoardEffectId]
if !ok {
log.Printf("[CharacterBoardService] unknown statusUpId=%d", eff.CharacterBoardEffectId)
return
}
characterId := s.resolveCharacterId(statusUp.CharacterBoardEffectTargetGroupId)
if characterId == 0 {
return
}
supType := model.CharacterBoardStatusUpType(statusUp.CharacterBoardStatusUpType)
calcType := model.StatusUpTypeToCalcType(supType)
key := store.CharacterBoardStatusUpKey{
CharacterId: characterId,
StatusCalculationType: int32(calcType),
}
state := user.CharacterBoardStatusUps[key]
state.CharacterId = characterId
state.StatusCalculationType = int32(calcType)
switch supType {
case model.CharacterBoardStatusUpTypeAgilityAdd, model.CharacterBoardStatusUpTypeAgilityMultiply:
state.Agility += eff.EffectValue
case model.CharacterBoardStatusUpTypeAttackAdd, model.CharacterBoardStatusUpTypeAttackMultiply:
state.Attack += eff.EffectValue
case model.CharacterBoardStatusUpTypeCritAttackAdd:
state.CriticalAttack += eff.EffectValue
case model.CharacterBoardStatusUpTypeCritRatioAdd:
state.CriticalRatio += eff.EffectValue
case model.CharacterBoardStatusUpTypeHpAdd, model.CharacterBoardStatusUpTypeHpMultiply:
state.Hp += eff.EffectValue
case model.CharacterBoardStatusUpTypeVitalityAdd, model.CharacterBoardStatusUpTypeVitalityMultiply:
state.Vitality += eff.EffectValue
}
user.CharacterBoardStatusUps[key] = state
}
func (s *CharacterBoardServiceServer) resolveCharacterId(targetGroupId int32) int32 {
targets := s.catalog.EffectTargetsByGroupId[targetGroupId]
for _, t := range targets {
if t.TargetValue != 0 {
return t.TargetValue
}
}
log.Printf("[CharacterBoardService] no characterId resolved for targetGroupId=%d", targetGroupId)
return 0
}
@@ -0,0 +1,68 @@
package service
import (
"context"
"encoding/json"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"google.golang.org/protobuf/types/known/emptypb"
)
type CharacterViewerServiceServer struct {
pb.UnimplementedCharacterViewerServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CharacterViewerCatalog
}
func NewCharacterViewerServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterViewerCatalog) *CharacterViewerServiceServer {
return &CharacterViewerServiceServer{users: users, sessions: sessions, catalog: catalog}
}
func (s *CharacterViewerServiceServer) CharacterViewerTop(ctx context.Context, _ *emptypb.Empty) (*pb.CharacterViewerTopResponse, error) {
log.Printf("[CharacterViewerService] CharacterViewerTop")
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
panic(fmt.Sprintf("CharacterViewerTop: no user for userId=%d: %v", userId, err))
}
released := s.catalog.ReleasedFieldIds(user)
log.Printf("[CharacterViewerService] released %d fields for user %d", len(released), userId)
now := gametime.NowMillis()
records := make([]map[string]any, 0, len(released))
for _, fieldId := range released {
records = append(records, map[string]any{
"userId": userId,
"characterViewerFieldId": fieldId,
"releaseDatetime": now,
"latestVersion": 0,
})
}
payload := "[]"
if len(records) > 0 {
data, _ := json.Marshal(records)
payload = string(data)
}
diff := map[string]*pb.DiffData{
"IUserCharacterViewerField": {
UpdateRecordsJson: payload,
DeleteKeysJson: "[]",
},
}
return &pb.CharacterViewerTopResponse{
ReleaseCharacterViewerFieldId: released,
DiffUserData: diff,
}, nil
}
+86
View File
@@ -0,0 +1,86 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
const companionMaxLevel = int32(50)
var companionDiffTables = []string{
"IUserCompanion",
"IUserMaterial",
"IUserConsumableItem",
}
type CompanionServiceServer struct {
pb.UnimplementedCompanionServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CompanionCatalog
config *masterdata.GameConfig
}
func NewCompanionServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CompanionCatalog, config *masterdata.GameConfig) *CompanionServiceServer {
return &CompanionServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
}
func (s *CompanionServiceServer) Enhance(ctx context.Context, req *pb.CompanionEnhanceRequest) (*pb.CompanionEnhanceResponse, error) {
log.Printf("[CompanionService] Enhance: uuid=%s addLevel=%d", req.UserCompanionUuid, req.AddLevelCount)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
companion, ok := user.Companions[req.UserCompanionUuid]
if !ok {
log.Printf("[CompanionService] Enhance: companion uuid=%s not found", req.UserCompanionUuid)
return
}
compDef, ok := s.catalog.CompanionById[companion.CompanionId]
if !ok {
log.Printf("[CompanionService] Enhance: companion master id=%d not found", companion.CompanionId)
return
}
targetLevel := companion.Level + req.AddLevelCount
if targetLevel > companionMaxLevel {
targetLevel = companionMaxLevel
}
for lvl := companion.Level; lvl < targetLevel; lvl++ {
if costFunc, ok := s.catalog.GoldCostByCategory[compDef.CompanionCategoryType]; ok {
goldCost := costFunc.Evaluate(lvl)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
}
matKey := masterdata.CompanionLevelKey{CategoryType: compDef.CompanionCategoryType, Level: lvl}
if mat, ok := s.catalog.MaterialsByKey[matKey]; ok {
user.Materials[mat.MaterialId] -= mat.Count
}
}
companion.Level = targetLevel
companion.LatestVersion = nowMillis
user.Companions[req.UserCompanionUuid] = companion
log.Printf("[CompanionService] Enhance: companionId=%d level -> %d", companion.CompanionId, targetLevel)
})
if err != nil {
return nil, fmt.Errorf("companion enhance: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, companionDiffTables))
return &pb.CompanionEnhanceResponse{
DiffUserData: diff,
}, nil
}
+47
View File
@@ -0,0 +1,47 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/userdata"
"google.golang.org/protobuf/types/known/emptypb"
)
type ConfigServiceServer struct {
pb.UnimplementedConfigServiceServer
GrpcHost string
GrpcPort int32
OctoURL string // HTTP base URL for Octo (list + assets); client uses this instead of default resources.app.nierreincarnation.com
}
func NewConfigServiceServer(host string, port int32, octoURL string) *ConfigServiceServer {
return &ConfigServiceServer{GrpcHost: host, GrpcPort: port, OctoURL: octoURL}
}
func (s *ConfigServiceServer) GetReviewServerConfig(ctx context.Context, _ *emptypb.Empty) (*pb.GetReviewServerConfigResponse, error) {
log.Printf("[ConfigService] GetReviewServerConfig -> %s:%d", s.GrpcHost, s.GrpcPort)
return &pb.GetReviewServerConfigResponse{
Api: &pb.ApiConfig{
Hostname: s.GrpcHost,
Port: s.GrpcPort,
},
Octo: &pb.OctoConfig{
Version: 1,
AppId: 1,
ClientSecretKey: "secret",
AesKey: "aeskey",
Url: s.OctoURL,
},
WebView: &pb.WebViewConfig{
BaseUrl: s.OctoURL,
},
MasterData: &pb.MasterDataConfig{
UrlFormat: s.OctoURL + "/master-data/%s",
},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
+43
View File
@@ -0,0 +1,43 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type ContentsStoryServiceServer struct {
pb.UnimplementedContentsStoryServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewContentsStoryServiceServer(users store.UserRepository, sessions store.SessionRepository) *ContentsStoryServiceServer {
return &ContentsStoryServiceServer{users: users, sessions: sessions}
}
func (s *ContentsStoryServiceServer) RegisterPlayed(ctx context.Context, req *pb.ContentsStoryRegisterPlayedRequest) (*pb.ContentsStoryRegisterPlayedResponse, error) {
log.Printf("[ContentsStoryService] RegisterPlayed: contentsStoryId=%d", req.ContentsStoryId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
user.ContentsStories[req.ContentsStoryId] = nowMillis
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserContentsStory"}))
return &pb.ContentsStoryRegisterPlayedResponse{
DiffUserData: diff,
}, nil
}
+396
View File
@@ -0,0 +1,396 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/gameutil"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
var costumeDiffTables = []string{
"IUserCostume",
"IUserMaterial",
"IUserConsumableItem",
}
type CostumeServiceServer struct {
pb.UnimplementedCostumeServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CostumeCatalog
config *masterdata.GameConfig
}
func NewCostumeServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CostumeCatalog, config *masterdata.GameConfig) *CostumeServiceServer {
return &CostumeServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
}
func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceRequest) (*pb.EnhanceResponse, error) {
log.Printf("[CostumeService] Enhance: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
costume, ok := user.Costumes[req.UserCostumeUuid]
if !ok {
log.Printf("[CostumeService] Enhance: costume uuid=%s not found", req.UserCostumeUuid)
return
}
cm, ok := s.catalog.Costumes[costume.CostumeId]
if !ok {
log.Printf("[CostumeService] Enhance: costume master id=%d not found", costume.CostumeId)
return
}
totalExp := int32(0)
totalMaterialCount := int32(0)
for materialId, count := range req.Materials {
mat, ok := s.catalog.Materials[materialId]
if !ok {
log.Printf("[CostumeService] Enhance: material id=%d not found, skipping", materialId)
continue
}
cur := user.Materials[materialId]
if cur < count {
log.Printf("[CostumeService] Enhance: insufficient material id=%d have=%d need=%d", materialId, cur, count)
continue
}
user.Materials[materialId] = cur - count
totalMaterialCount += count
expPerUnit := mat.EffectValue
if mat.WeaponType != 0 && mat.WeaponType == cm.SkillfulWeaponType {
expPerUnit = expPerUnit * s.config.MaterialSameWeaponExpCoefficientPermil / 1000
}
totalExp += expPerUnit * count
}
if costFunc, ok := s.catalog.EnhanceCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
log.Printf("[CostumeService] Enhance: gold cost=%d (materials=%d)", goldCost, totalMaterialCount)
}
costume.Exp += totalExp
if thresholds, ok := s.catalog.ExpByRarity[cm.RarityType]; ok {
costume.Level, costume.Exp = gameutil.LevelAndCap(costume.Exp, thresholds)
}
costume.LatestVersion = nowMillis
user.Costumes[req.UserCostumeUuid] = costume
log.Printf("[CostumeService] Enhance: costumeId=%d +%d exp -> total=%d level=%d", costume.CostumeId, totalExp, costume.Exp, costume.Level)
})
if err != nil {
return nil, fmt.Errorf("costume enhance: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, costumeDiffTables))
return &pb.EnhanceResponse{
IsGreatSuccess: false,
SurplusEnhanceMaterial: map[int32]int32{},
DiffUserData: diff,
}, nil
}
var awakenDiffTables = []string{
"IUserCostume",
"IUserMaterial",
"IUserConsumableItem",
"IUserCostumeAwakenStatusUp",
"IUserThought",
}
func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest) (*pb.AwakenResponse, error) {
log.Printf("[CostumeService] Awaken: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
costume, ok := user.Costumes[req.UserCostumeUuid]
if !ok {
log.Printf("[CostumeService] Awaken: costume uuid=%s not found", req.UserCostumeUuid)
return
}
awakenRow, ok := s.catalog.AwakenByCostumeId[costume.CostumeId]
if !ok {
log.Printf("[CostumeService] Awaken: no awaken data for costumeId=%d", costume.CostumeId)
return
}
nextStep := costume.AwakenCount + 1
if gold, ok := s.catalog.AwakenPriceByGroup[awakenRow.CostumeAwakenPriceGroupId]; ok {
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= gold
log.Printf("[CostumeService] Awaken: gold cost=%d", gold)
}
for materialId, count := range req.Materials {
cur := user.Materials[materialId]
if cur < count {
log.Printf("[CostumeService] Awaken: insufficient material id=%d have=%d need=%d", materialId, cur, count)
count = cur
}
user.Materials[materialId] = cur - count
}
costume.AwakenCount = nextStep
costume.LatestVersion = nowMillis
user.Costumes[req.UserCostumeUuid] = costume
log.Printf("[CostumeService] Awaken: costumeId=%d awakenCount=%d", costume.CostumeId, nextStep)
effectSteps, ok := s.catalog.AwakenEffectsByGroupAndStep[awakenRow.CostumeAwakenEffectGroupId]
if !ok {
return
}
effect, ok := effectSteps[nextStep]
if !ok {
return
}
switch model.CostumeAwakenEffectType(effect.CostumeAwakenEffectType) {
case model.CostumeAwakenEffectTypeStatusUp:
s.applyAwakenStatusUp(user, req.UserCostumeUuid, effect.CostumeAwakenEffectId, nowMillis)
case model.CostumeAwakenEffectTypeAbility:
log.Printf("[CostumeService] Awaken: ability effect id=%d (client-resolved)", effect.CostumeAwakenEffectId)
case model.CostumeAwakenEffectTypeItemAcquire:
s.applyAwakenItemAcquire(user, effect.CostumeAwakenEffectId, nowMillis)
default:
log.Printf("[CostumeService] Awaken: unknown effect type=%d", effect.CostumeAwakenEffectType)
}
})
if err != nil {
return nil, fmt.Errorf("costume awaken: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, awakenDiffTables))
return &pb.AwakenResponse{
DiffUserData: diff,
}, nil
}
func (s *CostumeServiceServer) applyAwakenStatusUp(user *store.UserState, costumeUuid string, statusUpGroupId int32, nowMillis int64) {
rows, ok := s.catalog.AwakenStatusUpByGroup[statusUpGroupId]
if !ok {
log.Printf("[CostumeService] Awaken: status up group %d not found", statusUpGroupId)
return
}
for _, row := range rows {
calcType := model.StatusCalculationType(row.StatusCalculationType)
key := store.CostumeAwakenStatusKey{
UserCostumeUuid: costumeUuid,
StatusCalculationType: calcType,
}
state := user.CostumeAwakenStatusUps[key]
state.UserCostumeUuid = costumeUuid
state.StatusCalculationType = calcType
switch model.StatusKindType(row.StatusKindType) {
case model.StatusKindTypeHp:
state.Hp += row.EffectValue
case model.StatusKindTypeAttack:
state.Attack += row.EffectValue
case model.StatusKindTypeVitality:
state.Vitality += row.EffectValue
case model.StatusKindTypeAgility:
state.Agility += row.EffectValue
case model.StatusKindTypeCriticalRatio:
state.CriticalRatio += row.EffectValue
case model.StatusKindTypeCriticalAttack:
state.CriticalAttack += row.EffectValue
}
state.LatestVersion = nowMillis
user.CostumeAwakenStatusUps[key] = state
}
}
func (s *CostumeServiceServer) applyAwakenItemAcquire(user *store.UserState, itemAcquireId int32, nowMillis int64) {
acq, ok := s.catalog.AwakenItemAcquireById[itemAcquireId]
if !ok {
log.Printf("[CostumeService] Awaken: item acquire id=%d not found", itemAcquireId)
return
}
key := fmt.Sprintf("awaken-thought-%d", acq.PossessionId)
if _, exists := user.Thoughts[key]; exists {
return
}
user.Thoughts[key] = store.ThoughtState{
UserThoughtUuid: key,
ThoughtId: acq.PossessionId,
AcquisitionDatetime: nowMillis,
LatestVersion: nowMillis,
}
log.Printf("[CostumeService] Awaken: granted thought id=%d", acq.PossessionId)
}
var activeSkillDiffTables = []string{
"IUserCostumeActiveSkill",
"IUserMaterial",
"IUserConsumableItem",
}
func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.EnhanceActiveSkillRequest) (*pb.EnhanceActiveSkillResponse, error) {
log.Printf("[CostumeService] EnhanceActiveSkill: uuid=%s addLevel=%d", req.UserCostumeUuid, req.AddLevelCount)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
costume, ok := user.Costumes[req.UserCostumeUuid]
if !ok {
log.Printf("[CostumeService] EnhanceActiveSkill: costume uuid=%s not found", req.UserCostumeUuid)
return
}
cm, ok := s.catalog.Costumes[costume.CostumeId]
if !ok {
log.Printf("[CostumeService] EnhanceActiveSkill: costume master id=%d not found", costume.CostumeId)
return
}
groupRows := s.catalog.ActiveSkillGroupsByGroupId[cm.CostumeActiveSkillGroupId]
enhanceMatId := int32(-1)
for _, g := range groupRows {
if g.CostumeLimitBreakCountLowerLimit <= costume.LimitBreakCount {
enhanceMatId = g.CostumeActiveSkillEnhancementMaterialId
break
}
}
if enhanceMatId < 0 {
log.Printf("[CostumeService] EnhanceActiveSkill: no skill group for costumeId=%d groupId=%d lb=%d",
costume.CostumeId, cm.CostumeActiveSkillGroupId, costume.LimitBreakCount)
return
}
skill := user.CostumeActiveSkills[req.UserCostumeUuid]
currentLevel := skill.Level
maxLevelFunc, ok := s.catalog.ActiveSkillMaxLevelByRarity[cm.RarityType]
if !ok {
log.Printf("[CostumeService] EnhanceActiveSkill: no max level func for rarity=%d", cm.RarityType)
return
}
maxLevel := maxLevelFunc.Evaluate(0)
addCount := req.AddLevelCount
if currentLevel+addCount > maxLevel {
addCount = maxLevel - currentLevel
}
if addCount <= 0 {
log.Printf("[CostumeService] EnhanceActiveSkill: already at max level %d", currentLevel)
return
}
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
key := [2]int32{enhanceMatId, lvl}
mats := s.catalog.ActiveSkillEnhanceMats[key]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
if cur < cost {
log.Printf("[CostumeService] EnhanceActiveSkill: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost)
cost = cur
}
user.Materials[mat.MaterialId] = cur - cost
}
if costFunc, ok := s.catalog.ActiveSkillCostByRarity[cm.RarityType]; ok {
goldCost := costFunc.Evaluate(lvl + 1)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
}
}
skill.UserCostumeUuid = req.UserCostumeUuid
skill.Level = currentLevel + addCount
skill.LatestVersion = nowMillis
user.CostumeActiveSkills[req.UserCostumeUuid] = skill
log.Printf("[CostumeService] EnhanceActiveSkill: costumeId=%d level %d -> %d", costume.CostumeId, currentLevel, skill.Level)
})
if err != nil {
return nil, fmt.Errorf("costume enhance active skill: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, activeSkillDiffTables))
return &pb.EnhanceActiveSkillResponse{
DiffUserData: diff,
}, nil
}
func (s *CostumeServiceServer) LimitBreak(ctx context.Context, req *pb.LimitBreakRequest) (*pb.LimitBreakResponse, error) {
log.Printf("[CostumeService] LimitBreak: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
costume, ok := user.Costumes[req.UserCostumeUuid]
if !ok {
log.Printf("[CostumeService] LimitBreak: costume uuid=%s not found", req.UserCostumeUuid)
return
}
if costume.LimitBreakCount >= s.config.CostumeLimitBreakAvailableCount {
log.Printf("[CostumeService] LimitBreak: already at max limit break %d", costume.LimitBreakCount)
return
}
cm, ok := s.catalog.Costumes[costume.CostumeId]
if !ok {
log.Printf("[CostumeService] LimitBreak: costume master id=%d not found", costume.CostumeId)
return
}
totalMaterialCount := int32(0)
for materialId, count := range req.Materials {
cur := user.Materials[materialId]
if cur < count {
log.Printf("[CostumeService] LimitBreak: insufficient material id=%d have=%d need=%d", materialId, cur, count)
count = cur
}
user.Materials[materialId] = cur - count
totalMaterialCount += count
}
if costFunc, ok := s.catalog.LimitBreakCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
log.Printf("[CostumeService] LimitBreak: gold cost=%d", goldCost)
}
costume.LimitBreakCount++
costume.LatestVersion = nowMillis
user.Costumes[req.UserCostumeUuid] = costume
log.Printf("[CostumeService] LimitBreak: costumeId=%d limitBreak -> %d", costume.CostumeId, costume.LimitBreakCount)
})
if err != nil {
return nil, fmt.Errorf("costume limit break: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, costumeDiffTables))
return &pb.LimitBreakResponse{
DiffUserData: diff,
}, nil
}
+166
View File
@@ -0,0 +1,166 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
"google.golang.org/protobuf/types/known/emptypb"
)
type DataServiceServer struct {
pb.UnimplementedDataServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewDataServiceServer(users store.UserRepository, sessions store.SessionRepository) *DataServiceServer {
return &DataServiceServer{users: users, sessions: sessions}
}
func (s *DataServiceServer) GetLatestMasterDataVersion(ctx context.Context, _ *emptypb.Empty) (*pb.MasterDataGetLatestVersionResponse, error) {
log.Printf("[DataService] GetLatestMasterDataVersion")
return &pb.MasterDataGetLatestVersionResponse{
LatestMasterDataVersion: "20240404193219",
}, nil
}
func (s *DataServiceServer) GetUserDataNameV2(ctx context.Context, _ *emptypb.Empty) (*pb.UserDataGetNameResponseV2, error) {
log.Printf("[DataService] GetUserDataNameV2")
return &pb.UserDataGetNameResponseV2{
TableNameList: []*pb.TableNameList{
{TableName: defaultTableNames()},
},
}, nil
}
func (s *DataServiceServer) GetUserData(ctx context.Context, req *pb.UserDataGetRequest) (*pb.UserDataGetResponse, error) {
log.Printf("[DataService] GetUserData: tables=%v", req.TableName)
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return nil, fmt.Errorf("snapshot user: %w", err)
}
defaults := userdata.FirstEntranceClientTableMap(user)
result := userdata.SelectTables(defaults, req.TableName)
return &pb.UserDataGetResponse{
UserDataJson: result,
}, nil
}
func defaultTableNames() []string {
return []string{
"IUser",
"IUserApple",
"IUserAutoSaleSettingDetail",
"IUserBeginnerCampaign",
"IUserBigHuntMaxScore",
"IUserBigHuntProgressStatus",
"IUserBigHuntScheduleMaxScore",
"IUserBigHuntStatus",
"IUserBigHuntWeeklyMaxScore",
"IUserBigHuntWeeklyStatus",
"IUserCageOrnamentReward",
"IUserCharacter",
"IUserCharacterBoard",
"IUserCharacterBoardAbility",
"IUserCharacterBoardCompleteReward",
"IUserCharacterBoardStatusUp",
"IUserCharacterCostumeLevelBonus",
"IUserCharacterRebirth",
"IUserCharacterViewerField",
"IUserComebackCampaign",
"IUserCompanion",
"IUserConsumableItem",
"IUserContentsStory",
"IUserCostume",
"IUserCostumeActiveSkill",
"IUserCostumeAwakenStatusUp",
"IUserCostumeLevelBonusReleaseStatus",
"IUserCostumeLotteryEffect",
"IUserCostumeLotteryEffectAbility",
"IUserCostumeLotteryEffectPending",
"IUserCostumeLotteryEffectStatusUp",
"IUserDeck",
"IUserDeckCharacter",
"IUserDeckCharacterDressupCostume",
"IUserDeckLimitContentRestricted",
"IUserDeckPartsGroup",
"IUserDeckSubWeaponGroup",
"IUserDeckTypeNote",
"IUserDokan",
"IUserEventQuestDailyGroupCompleteReward",
"IUserEventQuestGuerrillaFreeOpen",
"IUserEventQuestLabyrinthSeason",
"IUserEventQuestLabyrinthStage",
"IUserEventQuestProgressStatus",
"IUserEventQuestTowerAccumulationReward",
"IUserExplore",
"IUserExploreScore",
"IUserExtraQuestProgressStatus",
"IUserFacebook",
"IUserGem",
"IUserGimmick",
"IUserGimmickOrnamentProgress",
"IUserGimmickSequence",
"IUserGimmickUnlock",
"IUserImportantItem",
"IUserLimitedOpen",
// "IUserLogin",
"IUserLoginBonus",
"IUserMainQuestFlowStatus",
"IUserMainQuestMainFlowStatus",
"IUserMainQuestProgressStatus",
"IUserMainQuestReplayFlowStatus",
"IUserMainQuestSeasonRoute",
"IUserMaterial",
"IUserMission",
"IUserMissionCompletionProgress",
"IUserMissionPassPoint",
"IUserMovie",
"IUserNaviCutIn",
"IUserOmikuji",
"IUserParts",
"IUserPartsGroupNote",
"IUserPartsPreset",
"IUserPartsPresetTag",
"IUserPartsStatusSub",
"IUserPortalCageStatus",
"IUserPossessionAutoConvert",
"IUserPremiumItem",
"IUserProfile",
"IUserPvpDefenseDeck",
"IUserPvpStatus",
"IUserPvpWeeklyResult",
"IUserQuest",
"IUserQuestAutoOrbit",
"IUserQuestLimitContentStatus",
"IUserQuestMission",
"IUserQuestReplayFlowRewardGroup",
"IUserQuestSceneChoice",
"IUserQuestSceneChoiceHistory",
// "IUserSetting",
"IUserShopItem",
"IUserShopReplaceable",
"IUserShopReplaceableLineup",
"IUserSideStoryQuest",
"IUserSideStoryQuestSceneProgressStatus",
"IUserStatus",
"IUserThought",
"IUserTripleDeck",
"IUserTutorialProgress",
"IUserWeapon",
"IUserWeaponAbility",
"IUserWeaponAwaken",
"IUserWeaponNote",
"IUserWeaponSkill",
"IUserWeaponStory",
"IUserWebviewPanelMission",
}
}
+241
View File
@@ -0,0 +1,241 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type DeckServiceServer struct {
pb.UnimplementedDeckServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewDeckServiceServer(users store.UserRepository, sessions store.SessionRepository) *DeckServiceServer {
return &DeckServiceServer{users: users, sessions: sessions}
}
func (s *DeckServiceServer) UpdateName(ctx context.Context, req *pb.UpdateNameRequest) (*pb.UpdateNameResponse, error) {
log.Printf("[DeckService] UpdateName: deckType=%d deckNumber=%d name=%q", req.DeckType, req.UserDeckNumber, req.Name)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
deckKey := store.DeckKey{DeckType: model.DeckType(req.DeckType), UserDeckNumber: req.UserDeckNumber}
deck := user.Decks[deckKey]
deck.Name = req.Name
user.Decks[deckKey] = deck
})
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserDeck"})
return &pb.UpdateNameResponse{
DiffUserData: userdata.BuildDiffFromTables(result),
}, nil
}
func (s *DeckServiceServer) RefreshDeckPower(ctx context.Context, req *pb.RefreshDeckPowerRequest) (*pb.RefreshDeckPowerResponse, error) {
log.Printf("[DeckService] RefreshDeckPower: deckType=%d deckNumber=%d", req.DeckType, req.UserDeckNumber)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
if req.DeckPower == nil {
log.Printf("[DeckService] RefreshDeckPower: deckPower is nil")
return
}
dt := model.DeckType(req.DeckType)
deckKey := store.DeckKey{DeckType: dt, UserDeckNumber: req.UserDeckNumber}
deck, ok := user.Decks[deckKey]
if !ok {
log.Fatalf("[DeckService] RefreshDeckPower: deck not found")
}
deck.Power = req.DeckPower.Power
user.Decks[deckKey] = deck
for _, cp := range []*pb.DeckCharacterPower{
req.DeckPower.DeckCharacterPower01,
req.DeckPower.DeckCharacterPower02,
req.DeckPower.DeckCharacterPower03,
} {
if cp == nil || cp.UserDeckCharacterUuid == "" {
continue
}
if dc, ok := user.DeckCharacters[cp.UserDeckCharacterUuid]; ok {
dc.Power = cp.Power
user.DeckCharacters[cp.UserDeckCharacterUuid] = dc
}
}
note := user.DeckTypeNotes[dt]
if req.DeckPower.Power > note.MaxDeckPower {
note.DeckType = dt
note.MaxDeckPower = req.DeckPower.Power
user.DeckTypeNotes[dt] = note
}
})
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
"IUserDeck", "IUserDeckCharacter", "IUserDeckTypeNote",
})
return &pb.RefreshDeckPowerResponse{
DiffUserData: userdata.BuildDiffFromTables(result),
}, nil
}
func (s *DeckServiceServer) RefreshMultiDeckPower(ctx context.Context, req *pb.RefreshMultiDeckPowerRequest) (*pb.RefreshMultiDeckPowerResponse, error) {
log.Printf("[DeckService] RefreshMultiDeckPower: %d entries", len(req.DeckPowerInfo))
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, info := range req.DeckPowerInfo {
if info.DeckPower == nil {
continue
}
dt := model.DeckType(info.DeckType)
deckKey := store.DeckKey{DeckType: dt, UserDeckNumber: info.UserDeckNumber}
deck, ok := user.Decks[deckKey]
if !ok {
log.Printf("[DeckService] RefreshMultiDeckPower: deck not found deckType=%d deckNumber=%d", info.DeckType, info.UserDeckNumber)
continue
}
deck.Power = info.DeckPower.Power
user.Decks[deckKey] = deck
for _, cp := range []*pb.DeckCharacterPower{
info.DeckPower.DeckCharacterPower01,
info.DeckPower.DeckCharacterPower02,
info.DeckPower.DeckCharacterPower03,
} {
if cp == nil || cp.UserDeckCharacterUuid == "" {
continue
}
if dc, ok := user.DeckCharacters[cp.UserDeckCharacterUuid]; ok {
dc.Power = cp.Power
user.DeckCharacters[cp.UserDeckCharacterUuid] = dc
}
}
note := user.DeckTypeNotes[dt]
if info.DeckPower.Power > note.MaxDeckPower {
note.DeckType = dt
note.MaxDeckPower = info.DeckPower.Power
user.DeckTypeNotes[dt] = note
}
}
})
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
"IUserDeck", "IUserDeckCharacter", "IUserDeckTypeNote",
})
return &pb.RefreshMultiDeckPowerResponse{
DiffUserData: userdata.BuildDiffFromTables(result),
}, nil
}
func deckSlotsFromProto(deck *pb.Deck) []store.DeckCharacterInput {
slots := make([]store.DeckCharacterInput, 3)
for i, ch := range []*pb.DeckCharacter{deck.Character01, deck.Character02, deck.Character03} {
if ch == nil {
continue
}
slots[i] = store.DeckCharacterInput{
UserCostumeUuid: ch.UserCostumeUuid,
MainUserWeaponUuid: ch.MainUserWeaponUuid,
SubWeaponUuids: ch.SubUserWeaponUuid,
PartsUuids: ch.UserPartsUuid,
UserCompanionUuid: ch.UserCompanionUuid,
UserThoughtUuid: ch.UserThoughtUuid,
DressupCostumeId: ch.DressupCostumeId,
}
}
return slots
}
func (s *DeckServiceServer) ReplaceDeck(ctx context.Context, req *pb.ReplaceDeckRequest) (*pb.ReplaceDeckResponse, error) {
log.Printf("[DeckService] ReplaceDeck: deckType=%d deckNumber=%d", req.DeckType, req.UserDeckNumber)
if req.Deck != nil {
for i, ch := range []*pb.DeckCharacter{req.Deck.Character01, req.Deck.Character02, req.Deck.Character03} {
if ch == nil {
continue
}
log.Printf("[DeckService] ReplaceDeck slot %d: costume=%s mainWeapon=%s subWeapons=%v companion=%s thought=%s",
i+1, ch.UserCostumeUuid, ch.MainUserWeaponUuid, ch.SubUserWeaponUuid, ch.UserCompanionUuid, ch.UserThoughtUuid)
}
}
userId := currentUserId(ctx, s.users, s.sessions)
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserDeckSubWeaponGroup", oldUser, userdata.DeckSubWeaponRecords,
[]string{"userId", "userDeckCharacterUuid", "userWeaponUuid"}).
Track("IUserDeckPartsGroup", oldUser, userdata.DeckPartsGroupRecords,
[]string{"userId", "userDeckCharacterUuid", "userPartsUuid"}).
Track("IUserDeckCharacterDressupCostume", oldUser, userdata.DeckDressupCostumeRecords,
[]string{"userId", "userDeckCharacterUuid"})
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
if req.Deck == nil {
return
}
store.ApplyDeckReplacement(user, model.DeckType(req.DeckType), req.UserDeckNumber, deckSlotsFromProto(req.Deck), gametime.NowMillis())
})
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
"IUserDeck", "IUserDeckCharacter", "IUserDeckSubWeaponGroup", "IUserDeckPartsGroup",
"IUserDeckCharacterDressupCostume",
})
return &pb.ReplaceDeckResponse{
DiffUserData: tracker.Apply(user, result),
}, nil
}
func (s *DeckServiceServer) ReplaceTripleDeck(ctx context.Context, req *pb.ReplaceTripleDeckRequest) (*pb.ReplaceTripleDeckResponse, error) {
log.Printf("[DeckService] ReplaceTripleDeck: deckType=%d deckNumber=%d", req.DeckType, req.UserDeckNumber)
userId := currentUserId(ctx, s.users, s.sessions)
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserDeckSubWeaponGroup", oldUser, userdata.DeckSubWeaponRecords,
[]string{"userId", "userDeckCharacterUuid", "userWeaponUuid"}).
Track("IUserDeckPartsGroup", oldUser, userdata.DeckPartsGroupRecords,
[]string{"userId", "userDeckCharacterUuid", "userPartsUuid"}).
Track("IUserDeckCharacterDressupCostume", oldUser, userdata.DeckDressupCostumeRecords,
[]string{"userId", "userDeckCharacterUuid"})
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
nowMillis := gametime.NowMillis()
for idx, detail := range []*pb.DeckDetail{req.DeckDetail01, req.DeckDetail02, req.DeckDetail03} {
if detail == nil || detail.Deck == nil {
continue
}
log.Printf("[DeckService] ReplaceTripleDeck detail %d: deckType=%d deckNumber=%d", idx+1, detail.DeckType, detail.UserDeckNumber)
if detail.Deck != nil {
for i, ch := range []*pb.DeckCharacter{detail.Deck.Character01, detail.Deck.Character02, detail.Deck.Character03} {
if ch == nil {
continue
}
log.Printf("[DeckService] ReplaceTripleDeck detail %d slot %d: costume=%s mainWeapon=%s subWeapons=%v companion=%s thought=%s",
idx+1, i+1, ch.UserCostumeUuid, ch.MainUserWeaponUuid, ch.SubUserWeaponUuid, ch.UserCompanionUuid, ch.UserThoughtUuid)
}
}
store.ApplyDeckReplacement(user, model.DeckType(detail.DeckType), detail.UserDeckNumber, deckSlotsFromProto(detail.Deck), nowMillis)
}
})
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
"IUserDeck", "IUserDeckCharacter", "IUserDeckSubWeaponGroup", "IUserDeckPartsGroup",
"IUserDeckCharacterDressupCostume",
})
return &pb.ReplaceTripleDeckResponse{
DiffUserData: tracker.Apply(user, result),
}, nil
}
+42
View File
@@ -0,0 +1,42 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type DokanServiceServer struct {
pb.UnimplementedDokanServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewDokanServiceServer(users store.UserRepository, sessions store.SessionRepository) *DokanServiceServer {
return &DokanServiceServer{users: users, sessions: sessions}
}
func (s *DokanServiceServer) RegisterDokanConfirmed(ctx context.Context, req *pb.RegisterDokanConfirmedRequest) (*pb.RegisterDokanConfirmedResponse, error) {
log.Printf("[DokanService] RegisterDokanConfirmed: dokanIds=%v", req.DokanId)
userId := currentUserId(ctx, s.users, s.sessions)
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, id := range req.DokanId {
user.DokanConfirmed[id] = true
}
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserDokan"}))
return &pb.RegisterDokanConfirmedResponse{
DiffUserData: diff,
}, nil
}
+170
View File
@@ -0,0 +1,170 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
const (
exploreStaminaRecovery = 1000 // millivalue added on finish
exploreRewardMaterialId = 100001
exploreRewardBaseCount = 1
)
var exploreDiffTables = []string{
"IUserExplore",
"IUserExploreScore",
}
var exploreFinishDiffTables = []string{
"IUserExplore",
"IUserExploreScore",
"IUserMaterial",
"IUserStatus",
}
type ExploreServiceServer struct {
pb.UnimplementedExploreServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.ExploreCatalog
}
func NewExploreServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.ExploreCatalog) *ExploreServiceServer {
return &ExploreServiceServer{users: users, sessions: sessions, catalog: catalog}
}
func (s *ExploreServiceServer) StartExplore(ctx context.Context, req *pb.StartExploreRequest) (*pb.StartExploreResponse, error) {
log.Printf("[ExploreService] StartExplore: exploreId=%d useConsumableItemId=%d", req.ExploreId, req.UseConsumableItemId)
if _, ok := s.catalog.Explores[req.ExploreId]; !ok {
return nil, fmt.Errorf("explore id=%d not found", req.ExploreId)
}
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
explore := s.catalog.Explores[req.ExploreId]
if req.UseConsumableItemId > 0 && explore.ConsumeItemCount > 0 {
cur := user.ConsumableItems[req.UseConsumableItemId]
user.ConsumableItems[req.UseConsumableItemId] = cur - explore.ConsumeItemCount
log.Printf("[ExploreService] StartExplore: consumed item=%d count=%d remaining=%d", req.UseConsumableItemId, explore.ConsumeItemCount, user.ConsumableItems[req.UseConsumableItemId])
}
user.Explore = store.ExploreState{
PlayingExploreId: req.ExploreId,
IsUseExploreTicket: false,
LatestPlayDatetime: nowMillis,
LatestVersion: nowMillis,
}
})
if err != nil {
return nil, fmt.Errorf("start explore: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, exploreDiffTables))
return &pb.StartExploreResponse{
DiffUserData: diff,
}, nil
}
func (s *ExploreServiceServer) FinishExplore(ctx context.Context, req *pb.FinishExploreRequest) (*pb.FinishExploreResponse, error) {
log.Printf("[ExploreService] FinishExplore: exploreId=%d score=%d", req.ExploreId, req.Score)
explore, ok := s.catalog.Explores[req.ExploreId]
if !ok {
return nil, fmt.Errorf("explore id=%d not found", req.ExploreId)
}
assetGradeIconId := s.catalog.GradeForScore(req.ExploreId, req.Score)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
rewardCount := int32(exploreRewardBaseCount) * explore.RewardLotteryCount
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
existing, exists := user.ExploreScores[req.ExploreId]
if !exists || req.Score > existing.MaxScore {
user.ExploreScores[req.ExploreId] = store.ExploreScoreState{
ExploreId: req.ExploreId,
MaxScore: req.Score,
MaxScoreUpdateDatetime: nowMillis,
LatestVersion: nowMillis,
}
}
user.Explore = store.ExploreState{
PlayingExploreId: 0,
IsUseExploreTicket: false,
LatestPlayDatetime: user.Explore.LatestPlayDatetime,
LatestVersion: nowMillis,
}
user.Status.StaminaMilliValue += exploreStaminaRecovery
user.Status.StaminaUpdateDatetime = nowMillis
user.Status.LatestVersion = nowMillis
log.Printf("[ExploreService] FinishExplore: stamina +%d -> %d", exploreStaminaRecovery, user.Status.StaminaMilliValue)
user.Materials[exploreRewardMaterialId] += rewardCount
log.Printf("[ExploreService] FinishExplore: granted material=%d count=%d", exploreRewardMaterialId, rewardCount)
})
if err != nil {
return nil, fmt.Errorf("finish explore: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, exploreFinishDiffTables))
rewards := []*pb.ExploreReward{
{
PossessionType: int32(model.PossessionTypeMaterial),
PossessionId: exploreRewardMaterialId,
Count: rewardCount,
},
}
return &pb.FinishExploreResponse{
AcquireStaminaCount: exploreStaminaRecovery,
ExploreReward: rewards,
AssetGradeIconId: assetGradeIconId,
DiffUserData: diff,
}, nil
}
func (s *ExploreServiceServer) RetireExplore(ctx context.Context, req *pb.RetireExploreRequest) (*pb.RetireExploreResponse, error) {
log.Printf("[ExploreService] RetireExplore: exploreId=%d", req.ExploreId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
user.Explore = store.ExploreState{
PlayingExploreId: 0,
IsUseExploreTicket: false,
LatestPlayDatetime: user.Explore.LatestPlayDatetime,
LatestVersion: nowMillis,
}
})
if err != nil {
return nil, fmt.Errorf("retire explore: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserExplore"}))
return &pb.RetireExploreResponse{
DiffUserData: diff,
}, nil
}
+53
View File
@@ -0,0 +1,53 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
type FriendServiceServer struct {
pb.UnimplementedFriendServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewFriendServiceServer(users store.UserRepository, sessions store.SessionRepository) *FriendServiceServer {
return &FriendServiceServer{users: users, sessions: sessions}
}
func (s *FriendServiceServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
log.Printf("[FriendService] GetUser: playerId=%d", req.PlayerId)
return &pb.GetUserResponse{DiffUserData: userdata.EmptyDiff()}, nil
}
func (s *FriendServiceServer) GetFriendList(ctx context.Context, req *pb.GetFriendListRequest) (*pb.GetFriendListResponse, error) {
log.Printf("[FriendService] GetFriendList")
return &pb.GetFriendListResponse{
FriendUser: []*pb.FriendUser{},
SendCheerCount: 0,
ReceivedCheerCount: 0,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *FriendServiceServer) GetFriendRequestList(ctx context.Context, req *emptypb.Empty) (*pb.GetFriendRequestListResponse, error) {
log.Printf("[FriendService] GetFriendRequestList")
return &pb.GetFriendRequestListResponse{
User: []*pb.User{},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *FriendServiceServer) SearchRecommendedUsers(ctx context.Context, req *emptypb.Empty) (*pb.SearchRecommendedUsersResponse, error) {
log.Printf("[FriendService] SearchRecommendedUsers")
return &pb.SearchRecommendedUsersResponse{
Users: []*pb.User{},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
+647
View File
@@ -0,0 +1,647 @@
package service
import (
"context"
"fmt"
"log"
"time"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
var gachaDiffTables = []string{
"IUserGem",
"IUserCostume",
"IUserWeapon",
"IUserConsumableItem",
"IUserCostumeActiveSkill",
"IUserWeaponNote",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserWeaponStory",
"IUserCharacter",
"IUserMaterial",
}
type GachaServiceServer struct {
pb.UnimplementedGachaServiceServer
users store.UserRepository
sessions store.SessionRepository
gacha store.GachaRepository
handler *gacha.GachaHandler
}
func NewGachaServiceServer(
users store.UserRepository,
sessions store.SessionRepository,
gachaRepo store.GachaRepository,
handler *gacha.GachaHandler,
) *GachaServiceServer {
return &GachaServiceServer{
users: users,
sessions: sessions,
gacha: gachaRepo,
handler: handler,
}
}
func (s *GachaServiceServer) GetGachaList(ctx context.Context, req *pb.GetGachaListRequest) (*pb.GetGachaListResponse, error) {
log.Printf("[GachaService] GetGachaList: labels=%v", req.GachaLabelType)
catalog, _ := s.gacha.SnapshotCatalog()
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, err := s.users.UpdateUser(userId, func(user *store.UserState) {
user.EnsureMaps()
s.autoConvertExpiredMedals(user, catalog, nowMillis)
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
gachaList := make([]*pb.Gacha, 0, len(catalog))
for _, entry := range catalog {
if !matchesGachaLabel(req.GachaLabelType, entry.GachaLabelType) {
continue
}
if entry.GachaLabelType == model.GachaLabelPortalCage || entry.GachaLabelType == model.GachaLabelRecycle {
continue
}
bs := user.Gacha.BannerStates[entry.GachaId]
gachaList = append(gachaList, toProtoGacha(entry, &bs))
}
return &pb.GetGachaListResponse{
Gacha: gachaList,
ConvertedGachaMedal: toProtoConvertedGachaMedal(user.Gacha.ConvertedGachaMedal),
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *GachaServiceServer) autoConvertExpiredMedals(user *store.UserState, catalog []store.GachaCatalogEntry, nowMillis int64) {
for _, entry := range catalog {
if entry.GachaMedalId == 0 || entry.EndDatetime == 0 {
continue
}
if nowMillis < entry.EndDatetime {
continue
}
bs, exists := user.Gacha.BannerStates[entry.GachaId]
if !exists || bs.MedalCount <= 0 {
continue
}
medalInfo, ok := s.handler.MedalInfo[entry.GachaId]
if !ok {
continue
}
conversionRate := medalInfo.ConversionRate
if conversionRate <= 0 {
conversionRate = 1
}
bookmarkCount := bs.MedalCount * conversionRate
user.ConsumableItems[medalInfo.ConsumableItemId] += bookmarkCount
user.Gacha.ConvertedGachaMedal.ConvertedMedalPossession = append(
user.Gacha.ConvertedGachaMedal.ConvertedMedalPossession,
store.ConsumableItemState{
ConsumableItemId: medalInfo.ConsumableItemId,
Count: bookmarkCount,
},
)
originalCount := bs.MedalCount
bs.MedalCount = 0
user.Gacha.BannerStates[entry.GachaId] = bs
log.Printf("[GachaService] auto-converted %d medals for gacha %d -> %d bookmarks (item %d)",
originalCount, entry.GachaId, bookmarkCount, medalInfo.ConsumableItemId)
}
}
func (s *GachaServiceServer) GetGacha(ctx context.Context, req *pb.GetGachaRequest) (*pb.GetGachaResponse, error) {
log.Printf("[GachaService] GetGacha: ids=%v", req.GachaId)
catalog, _ := s.gacha.SnapshotCatalog()
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return nil, fmt.Errorf("snapshot user: %w", err)
}
byId := make(map[int32]*pb.Gacha, len(req.GachaId))
for _, wantedId := range req.GachaId {
for _, entry := range catalog {
if entry.GachaId == wantedId {
bs := user.Gacha.BannerStates[entry.GachaId]
byId[wantedId] = toProtoGacha(entry, &bs)
break
}
}
}
return &pb.GetGachaResponse{
Gacha: byId,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb.DrawResponse, error) {
log.Printf("[GachaService] Draw: gachaId=%d phaseId=%d execCount=%d", req.GachaId, req.GachaPricePhaseId, req.ExecCount)
catalog, _ := s.gacha.SnapshotCatalog()
entry := findCatalogEntry(catalog, req.GachaId)
if entry == nil {
return nil, fmt.Errorf("gacha %d not found", req.GachaId)
}
userId := currentUserId(ctx, s.users, s.sessions)
execCount := req.ExecCount
if execCount <= 0 {
execCount = 1
}
var drawResult *gacha.DrawResult
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
var drawErr error
drawResult, drawErr = s.handler.HandleDraw(user, *entry, req.GachaPricePhaseId, execCount)
if drawErr != nil {
log.Printf("[GachaService] Draw error: %v", drawErr)
drawResult = &gacha.DrawResult{}
}
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
for i, item := range drawResult.Items {
if bonus, ok := drawResult.BonusItems[i]; ok {
log.Printf("[GachaService] drawn[%d]: type=%d id=%d rarity=%d + bonus type=%d id=%d rarity=%d",
i, item.PossessionType, item.PossessionId, item.RarityType,
bonus.PossessionType, bonus.PossessionId, bonus.RarityType)
} else {
log.Printf("[GachaService] drawn[%d]: type=%d id=%d rarity=%d",
i, item.PossessionType, item.PossessionId, item.RarityType)
}
}
gachaResults := make([]*pb.DrawGachaOddsItem, 0, len(drawResult.Items))
dupMap := make(map[int]gacha.DuplicateInfo)
for _, d := range drawResult.DuplicateInfos {
dupMap[d.Index] = d
}
bonusDupMap := make(map[int]gacha.DuplicateInfo)
for _, d := range drawResult.BonusDuplicateInfos {
bonusDupMap[d.Index] = d
}
costumePT := int32(model.PossessionTypeCostume)
weaponPT := int32(model.PossessionTypeWeapon)
isMaterialDraw := model.IsMaterialBanner(entry.GachaLabelType)
ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes))
for _, c := range updatedUser.Costumes {
ownedCostumes[c.CostumeId] = true
}
ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons))
for _, w := range updatedUser.Weapons {
ownedWeapons[w.WeaponId] = true
}
for i, item := range drawResult.Items {
isNew := !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser)
var oddsItem *pb.DrawGachaOddsItem
if isMaterialDraw {
oddsItem = &pb.DrawGachaOddsItem{
GachaItem: &pb.GachaItem{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
Count: 1,
IsNew: isNew,
},
GachaItemBonus: &pb.GachaItem{},
}
} else if bonus, hasBonusWeapon := drawResult.BonusItems[i]; hasBonusWeapon {
oddsItem = &pb.DrawGachaOddsItem{
GachaItem: &pb.GachaItem{
PossessionType: costumePT,
PossessionId: item.PossessionId,
Count: 1,
IsNew: isNew,
},
GachaItemBonus: &pb.GachaItem{
PossessionType: weaponPT,
PossessionId: bonus.PossessionId,
Count: 1,
IsNew: !ownedWeapons[bonus.PossessionId],
},
}
} else {
oddsItem = &pb.DrawGachaOddsItem{
GachaItem: &pb.GachaItem{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
Count: 1,
IsNew: isNew,
},
GachaItemBonus: &pb.GachaItem{},
}
}
if drawResult.MedalBonus > 0 && entry.MedalConsumableItemId != 0 {
oddsItem.MedalBonus = &pb.GachaBonus{
PossessionType: int32(model.PossessionTypeConsumableItem),
PossessionId: entry.MedalConsumableItemId,
Count: 0,
}
}
if dup, ok := dupMap[i]; ok {
applyDuplicationBonus(oddsItem, dup)
}
if bdup, ok := bonusDupMap[i]; ok {
applyDuplicationBonus(oddsItem, bdup)
}
gachaResults = append(gachaResults, oddsItem)
}
var bonuses []*pb.GachaBonus
for _, b := range drawResult.Bonuses {
bonuses = append(bonuses, &pb.GachaBonus{
PossessionType: b.PossessionType,
PossessionId: b.PossessionId,
Count: b.Count,
})
}
bs := updatedUser.Gacha.BannerStates[entry.GachaId]
nextGacha := toProtoGacha(*entry, &bs)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(
userdata.FullClientTableMap(updatedUser),
gachaDiffTables,
))
return &pb.DrawResponse{
NextGacha: nextGacha,
GachaResult: gachaResults,
GachaBonus: bonuses,
MenuGachaBadgeInfo: []*pb.MenuGachaBadgeInfo{},
DiffUserData: diff,
}, nil
}
func (s *GachaServiceServer) ResetBoxGacha(ctx context.Context, req *pb.ResetBoxGachaRequest) (*pb.ResetBoxGachaResponse, error) {
log.Printf("[GachaService] ResetBoxGacha: gachaId=%d", req.GachaId)
catalog, _ := s.gacha.SnapshotCatalog()
entry := findCatalogEntry(catalog, req.GachaId)
if entry == nil {
return nil, fmt.Errorf("gacha %d not found", req.GachaId)
}
userId := currentUserId(ctx, s.users, s.sessions)
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
if resetErr := s.handler.HandleResetBox(user, *entry); resetErr != nil {
log.Printf("[GachaService] ResetBoxGacha error: %v", resetErr)
}
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
bs := updatedUser.Gacha.BannerStates[entry.GachaId]
return &pb.ResetBoxGachaResponse{
Gacha: toProtoGacha(*entry, &bs),
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *GachaServiceServer) GetRewardGacha(ctx context.Context, req *emptypb.Empty) (*pb.GetRewardGachaResponse, error) {
log.Printf("[GachaService] GetRewardGacha")
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return nil, fmt.Errorf("snapshot user: %w", err)
}
maxCount := s.handler.Config.RewardGachaDailyMaxCount
if maxCount <= 0 {
maxCount = model.DefaultDailyDrawLimit
}
todayStart := gametime.StartOfDayMillis()
drawCount := user.Gacha.TodaysCurrentDrawCount
if user.Gacha.LastRewardDrawDate < todayStart {
drawCount = 0
}
return &pb.GetRewardGachaResponse{
Available: drawCount < maxCount,
TodaysCurrentDrawCount: drawCount,
DailyMaxCount: maxCount,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *GachaServiceServer) RewardDraw(ctx context.Context, req *pb.RewardDrawRequest) (*pb.RewardDrawResponse, error) {
log.Printf("[GachaService] RewardDraw: placement=%q reward=%q amount=%q", req.PlacementName, req.RewardName, req.RewardAmount)
userId := currentUserId(ctx, s.users, s.sessions)
var items []gacha.DrawnItem
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
var drawErr error
items, drawErr = s.handler.HandleRewardDraw(user, 1)
if drawErr != nil {
log.Printf("[GachaService] RewardDraw error: %v", drawErr)
}
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes))
for _, c := range updatedUser.Costumes {
ownedCostumes[c.CostumeId] = true
}
ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons))
for _, w := range updatedUser.Weapons {
ownedWeapons[w.WeaponId] = true
}
results := make([]*pb.RewardGachaItem, 0, len(items))
for _, item := range items {
results = append(results, &pb.RewardGachaItem{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
Count: 1,
IsNew: !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser),
})
}
tables := userdata.FullClientTableMap(updatedUser)
diff := userdata.BuildDiffFromTables(tables)
return &pb.RewardDrawResponse{
RewardGachaResult: results,
DiffUserData: diff,
}, nil
}
func findCatalogEntry(catalog []store.GachaCatalogEntry, gachaId int32) *store.GachaCatalogEntry {
for i := range catalog {
if catalog[i].GachaId == gachaId {
return &catalog[i]
}
}
return nil
}
func matchesGachaLabel(labels []int32, label int32) bool {
if len(labels) == 0 {
return true
}
for _, candidate := range labels {
if candidate == label {
return true
}
}
return false
}
func toProtoGacha(entry store.GachaCatalogEntry, bs *store.GachaBannerState) *pb.Gacha {
g := &pb.Gacha{
GachaId: entry.GachaId,
GachaLabelType: entry.GachaLabelType,
GachaModeType: entry.GachaModeType,
GachaAutoResetType: entry.GachaAutoResetType,
GachaAutoResetPeriod: entry.GachaAutoResetPeriod,
NextAutoResetDatetime: safeTimestamp(entry.NextAutoResetDatetime),
GachaUnlockCondition: []*pb.GachaUnlockCondition{{GachaUnlockConditionType: model.GachaUnlockNone, ConditionValue: 0}},
IsUserGachaUnlock: entry.IsUserGachaUnlock,
StartDatetime: safeTimestamp(entry.StartDatetime),
EndDatetime: safeTimestamp(entry.EndDatetime),
RelatedMainQuestChapterId: entry.RelatedMainQuestChapterId,
RelatedEventQuestChapterId: entry.RelatedEventQuestChapterId,
PromotionMovieAssetId: entry.PromotionMovieAssetId,
GachaMedalId: entry.GachaMedalId,
GachaDecorationType: entry.GachaDecorationType,
SortOrder: entry.SortOrder,
IsInactive: entry.IsInactive,
InformationId: entry.InformationId,
}
g.GachaPricePhase = buildProtoPricePhases(entry, bs)
promotionItems := buildProtoPromotionItems(entry)
switch entry.GachaModeType {
case model.GachaModeBox:
boxNumber := int32(1)
if bs != nil && bs.BoxNumber > 0 {
boxNumber = bs.BoxNumber
}
phaseId := int32(0)
if len(entry.PricePhases) > 0 {
phaseId = entry.PricePhases[0].PhaseId
}
g.GachaMode = &pb.Gacha_GachaModeBoxComposition{
GachaModeBoxComposition: &pb.GachaModeBoxComposition{
GachaBoxGroupId: entry.GroupId,
BoxNumber: boxNumber,
CurrentBoxNumber: boxNumber,
NaviCharacterCommentAssetName: "production",
GachaAssetName: entry.BannerAssetName,
GachaPricePhaseId: phaseId,
PromotionGachaOddsItem: promotionItems,
GachaDescriptionTextId: entry.DescriptionTextId,
},
}
case model.GachaModeStepup:
stepNumber := int32(1)
loopCount := int32(0)
if bs != nil {
if bs.StepNumber > 0 {
stepNumber = bs.StepNumber
}
loopCount = bs.LoopCount
}
g.GachaMode = &pb.Gacha_GachaModeStepupComposition{
GachaModeStepupComposition: &pb.GachaModeStepupComposition{
GachaStepGroupId: entry.GroupId,
StepNumber: 1,
CurrentStepNumber: stepNumber,
NaviCharacterCommentAssetName: "production",
GachaAssetName: entry.BannerAssetName,
PromotionGachaOddsItem: promotionItems,
CurrentLoopCount: loopCount,
},
}
default:
g.GachaMode = &pb.Gacha_GachaModeBasic{
GachaModeBasic: &pb.GachaModeBasic{
NaviCharacterCommentAssetName: "production",
GachaAssetName: entry.BannerAssetName,
PromotionGachaOddsItem: promotionItems,
},
}
}
return g
}
func buildProtoPricePhases(entry store.GachaCatalogEntry, bs *store.GachaBannerState) []*pb.GachaPricePhase {
phases := make([]*pb.GachaPricePhase, 0, len(entry.PricePhases))
for _, p := range entry.PricePhases {
isEnabled := true
if entry.GachaModeType == model.GachaModeStepup && bs != nil {
currentStep := bs.StepNumber
if currentStep <= 0 {
currentStep = 1
}
isEnabled = p.StepNumber == currentStep
}
var bonuses []*pb.GachaBonus
for _, b := range p.Bonuses {
bonuses = append(bonuses, &pb.GachaBonus{
PossessionType: b.PossessionType,
PossessionId: b.PossessionId,
Count: b.Count,
})
}
limitExec := p.LimitExecCount
if limitExec <= 0 {
limitExec = 999
}
phases = append(phases, &pb.GachaPricePhase{
GachaPricePhaseId: p.PhaseId,
IsEnabled: isEnabled,
EndDatetime: safeTimestamp(entry.EndDatetime),
PriceType: p.PriceType,
PriceId: p.PriceId,
Price: p.Price,
RegularPrice: p.RegularPrice,
DrawCount: p.DrawCount,
LimitExecCount: limitExec,
EachMaxExecCount: p.DrawCount,
GachaBonus: bonuses,
GachaOddsFixedRarity: &pb.GachaOddsFixedRarity{
FixedRarityTypeLowerLimit: p.FixedRarityMin,
FixedCount: p.FixedCount,
},
GachaBadgeType: model.GachaBadgeTypeNone,
})
}
return phases
}
func buildProtoPromotionItems(entry store.GachaCatalogEntry) []*pb.GachaOddsItem {
if len(entry.PromotionItems) == 0 {
return nil
}
isMaterial := model.IsMaterialBanner(entry.GachaLabelType)
items := make([]*pb.GachaOddsItem, 0, len(entry.PromotionItems))
for i, pi := range entry.PromotionItems {
bonus := &pb.GachaItem{}
if !isMaterial && pi.BonusPossessionType != 0 {
bonus = &pb.GachaItem{
PossessionType: pi.BonusPossessionType,
PossessionId: pi.BonusPossessionId,
Count: 1,
}
}
items = append(items, &pb.GachaOddsItem{
GachaItem: &pb.GachaItem{
PossessionType: pi.PossessionType,
PossessionId: pi.PossessionId,
Count: 1,
PromotionOrder: int32(i + 1),
},
GachaItemBonus: bonus,
MaxDrawableCount: 999,
IsTarget: pi.IsTarget,
})
}
return items
}
func toProtoConvertedGachaMedal(state store.ConvertedGachaMedalState) *pb.ConvertedGachaMedal {
items := make([]*pb.ConsumableItemPossession, 0, len(state.ConvertedMedalPossession))
for _, item := range state.ConvertedMedalPossession {
items = append(items, &pb.ConsumableItemPossession{
ConsumableItemId: item.ConsumableItemId,
Count: item.Count,
})
}
obtain := &pb.ConsumableItemPossession{
ConsumableItemId: 0,
Count: 0,
}
if state.ObtainPossession != nil {
obtain.ConsumableItemId = state.ObtainPossession.ConsumableItemId
obtain.Count = state.ObtainPossession.Count
}
return &pb.ConvertedGachaMedal{
ConvertedMedalPossession: items,
ObtainPossession: obtain,
}
}
func safeTimestamp(unixMillis int64) *timestamppb.Timestamp {
if unixMillis == 0 {
return &timestamppb.Timestamp{Seconds: 0}
}
return timestamppb.New(time.UnixMilli(unixMillis))
}
func applyDuplicationBonus(oddsItem *pb.DrawGachaOddsItem, dup gacha.DuplicateInfo) {
if oddsItem.DuplicationBonusGrade == 0 {
oddsItem.DuplicationBonusGrade = dup.Grade
}
for _, b := range dup.Bonuses {
oddsItem.DuplicationBonus = append(oddsItem.DuplicationBonus, &pb.GachaBonus{
PossessionType: b.PossessionType,
PossessionId: b.PossessionId,
Count: b.Count,
})
}
}
func isOwnedByType(item gacha.DrawnItem, costumes, weapons map[int32]bool, user store.UserState) bool {
switch item.PossessionType {
case int32(model.PossessionTypeCostume):
return costumes[item.PossessionId]
case int32(model.PossessionTypeWeapon):
return weapons[item.PossessionId]
case int32(model.PossessionTypeMaterial):
return user.Materials[item.PossessionId] > 0
case int32(model.PossessionTypeWeaponEnhanced):
return user.ConsumableItems[item.PossessionId] > 0
}
return false
}
+28
View File
@@ -0,0 +1,28 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/userdata"
)
type GameplayServiceServer struct {
pb.UnimplementedGamePlayServiceServer
}
func NewGameplayServiceServer() *GameplayServiceServer {
return &GameplayServiceServer{}
}
func (s *GameplayServiceServer) CheckBeforeGamePlay(ctx context.Context, req *pb.CheckBeforeGamePlayRequest) (*pb.CheckBeforeGamePlayResponse, error) {
log.Printf("[GamePlayService] CheckBeforeGamePlay: tr=%s voiceLang=%d textLang=%d",
req.Tr, req.VoiceClientSystemLanguageTypeId, req.TextClientSystemLanguageTypeId)
return &pb.CheckBeforeGamePlayResponse{
IsExistUnreadPop: false,
MenuGachaBadgeInfo: []*pb.MenuGachaBadgeInfo{},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
+159
View File
@@ -0,0 +1,159 @@
package service
import (
"context"
"fmt"
"log"
"slices"
"sort"
"time"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
type GiftServiceServer struct {
pb.UnimplementedGiftServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewGiftServiceServer(users store.UserRepository, sessions store.SessionRepository) *GiftServiceServer {
return &GiftServiceServer{users: users, sessions: sessions}
}
func (s *GiftServiceServer) ReceiveGift(ctx context.Context, req *pb.ReceiveGiftRequest) (*pb.ReceiveGiftResponse, error) {
log.Printf("[GiftService] ReceiveGift: giftUuids=%d", len(req.UserGiftUuid))
userId := currentUserId(ctx, s.users, s.sessions)
received := make([]string, 0, len(req.UserGiftUuid))
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
nowMillis := gametime.NowMillis()
remaining := make([]store.NotReceivedGiftState, 0, len(user.Gifts.NotReceived))
for _, gift := range user.Gifts.NotReceived {
if slices.Contains(req.UserGiftUuid, gift.UserGiftUuid) {
received = append(received, gift.UserGiftUuid)
user.Gifts.Received = append(user.Gifts.Received, store.ReceivedGiftState{
GiftCommon: gift.GiftCommon,
ReceivedDatetime: nowMillis,
})
continue
}
remaining = append(remaining, gift)
}
user.Gifts.NotReceived = remaining
user.Notifications.GiftNotReceiveCount = int32(len(user.Gifts.NotReceived))
})
if err != nil {
return &pb.ReceiveGiftResponse{
ReceivedGiftUuid: []string{},
ExpiredGiftUuid: []string{},
OverflowGiftUuid: []string{},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
return &pb.ReceiveGiftResponse{
ReceivedGiftUuid: received,
ExpiredGiftUuid: []string{},
OverflowGiftUuid: []string{},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *GiftServiceServer) GetGiftList(ctx context.Context, req *pb.GetGiftListRequest) (*pb.GetGiftListResponse, error) {
log.Printf("[GiftService] GetGiftList: rewardKinds=%v expirationType=%d ascending=%v nextCursor=%d previousCursor=%d getCount=%d",
req.RewardKindType, req.ExpirationType, req.IsAscendingSort, req.NextCursor, req.PreviousCursor, req.GetCount)
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return nil, fmt.Errorf("snapshot user: %w", err)
}
gifts := append([]store.NotReceivedGiftState(nil), user.Gifts.NotReceived...)
sort.Slice(gifts, func(i, j int) bool {
if req.IsAscendingSort {
return gifts[i].ExpirationDatetime < gifts[j].ExpirationDatetime
}
return gifts[i].ExpirationDatetime > gifts[j].ExpirationDatetime
})
if req.GetCount > 0 && len(gifts) > int(req.GetCount) {
gifts = gifts[:req.GetCount]
}
items := make([]*pb.NotReceivedGift, 0, len(gifts))
for _, gift := range gifts {
items = append(items, &pb.NotReceivedGift{
GiftCommon: toProtoGiftCommon(gift.GiftCommon),
ExpirationDatetime: timestampOrNilGift(gift.ExpirationDatetime),
UserGiftUuid: gift.UserGiftUuid,
})
}
return &pb.GetGiftListResponse{
Gift: items,
TotalPageCount: pageCount(len(user.Gifts.NotReceived), int(req.GetCount)),
NextCursor: 0,
PreviousCursor: 0,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *GiftServiceServer) GetGiftReceiveHistoryList(ctx context.Context, req *emptypb.Empty) (*pb.GetGiftReceiveHistoryListResponse, error) {
log.Printf("[GiftService] GetGiftReceiveHistoryList")
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return nil, fmt.Errorf("snapshot user: %w", err)
}
items := make([]*pb.ReceivedGift, 0, len(user.Gifts.Received))
for _, gift := range user.Gifts.Received {
items = append(items, &pb.ReceivedGift{
GiftCommon: toProtoGiftCommon(gift.GiftCommon),
ReceivedDatetime: timestampOrNilGift(gift.ReceivedDatetime),
})
}
return &pb.GetGiftReceiveHistoryListResponse{
Gift: items,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func toProtoGiftCommon(gift store.GiftCommonState) *pb.GiftCommon {
return &pb.GiftCommon{
PossessionType: gift.PossessionType,
PossessionId: gift.PossessionId,
Count: gift.Count,
GrantDatetime: timestampOrNilGift(gift.GrantDatetime),
DescriptionGiftTextId: gift.DescriptionGiftTextId,
EquipmentData: gift.EquipmentData,
}
}
func timestampOrNilGift(unixMillis int64) *timestamppb.Timestamp {
if unixMillis == 0 {
return nil
}
return timestamppb.New(time.UnixMilli(unixMillis))
}
func pageCount(total, pageSize int) int32 {
if total == 0 {
return 0
}
if pageSize <= 0 {
return 1
}
pages := total / pageSize
if total%pageSize != 0 {
pages++
}
return int32(pages)
}
+124
View File
@@ -0,0 +1,124 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
type GimmickServiceServer struct {
pb.UnimplementedGimmickServiceServer
users store.UserRepository
sessions store.SessionRepository
gimmickCatalog *masterdata.GimmickCatalog
}
func NewGimmickServiceServer(users store.UserRepository, sessions store.SessionRepository, gimmickCatalog *masterdata.GimmickCatalog) *GimmickServiceServer {
return &GimmickServiceServer{users: users, sessions: sessions, gimmickCatalog: gimmickCatalog}
}
func (s *GimmickServiceServer) UpdateSequence(ctx context.Context, req *pb.UpdateSequenceRequest) (*pb.UpdateSequenceResponse, error) {
log.Printf("[GimmickService] UpdateSequence: scheduleId=%d sequenceId=%d",
req.GimmickSequenceScheduleId, req.GimmickSequenceId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
key := store.GimmickSequenceKey{
GimmickSequenceScheduleId: req.GimmickSequenceScheduleId,
GimmickSequenceId: req.GimmickSequenceId,
}
sequence := user.Gimmick.Sequences[key]
sequence.Key = key
user.Gimmick.Sequences[key] = sequence
})
return &pb.UpdateSequenceResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserGimmickSequence"})),
}, nil
}
func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *pb.UpdateGimmickProgressRequest) (*pb.UpdateGimmickProgressResponse, error) {
log.Printf("[GimmickService] UpdateGimmickProgress: scheduleId=%d sequenceId=%d gimmickId=%d ornamentIndex=%d progressValueBit=%d flowType=%d",
req.GimmickSequenceScheduleId, req.GimmickSequenceId, req.GimmickId, req.GimmickOrnamentIndex, req.ProgressValueBit, req.FlowType)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
nowMillis := gametime.NowMillis()
progressKey := store.GimmickKey{
GimmickSequenceScheduleId: req.GimmickSequenceScheduleId,
GimmickSequenceId: req.GimmickSequenceId,
GimmickId: req.GimmickId,
}
progress := user.Gimmick.Progress[progressKey]
progress.Key = progressKey
progress.StartDatetime = nowMillis
user.Gimmick.Progress[progressKey] = progress
ornamentKey := store.GimmickOrnamentKey{
GimmickSequenceScheduleId: req.GimmickSequenceScheduleId,
GimmickSequenceId: req.GimmickSequenceId,
GimmickId: req.GimmickId,
GimmickOrnamentIndex: req.GimmickOrnamentIndex,
}
ornament := user.Gimmick.OrnamentProgress[ornamentKey]
ornament.Key = ornamentKey
ornament.ProgressValueBit = req.ProgressValueBit
ornament.BaseDatetime = nowMillis
user.Gimmick.OrnamentProgress[ornamentKey] = ornament
})
return &pb.UpdateGimmickProgressResponse{
GimmickOrnamentReward: []*pb.GimmickReward{},
IsSequenceCleared: false,
GimmickSequenceClearReward: []*pb.GimmickReward{},
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{
"IUserGimmick",
"IUserGimmickOrnamentProgress",
})),
}, nil
}
func (s *GimmickServiceServer) InitSequenceSchedule(ctx context.Context, _ *emptypb.Empty) (*pb.InitSequenceScheduleResponse, error) {
log.Printf("[GimmickService] InitSequenceSchedule")
userId := currentUserId(ctx, s.users, s.sessions)
now := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
added := 0
for _, key := range s.gimmickCatalog.ActiveScheduleKeys(*user, now) {
if _, exists := user.Gimmick.Sequences[key]; !exists {
user.Gimmick.Sequences[key] = store.GimmickSequenceState{Key: key}
added++
}
}
if added > 0 {
log.Printf("[GimmickService] InitSequenceSchedule: added %d sequences (total %d)", added, len(user.Gimmick.Sequences))
}
})
return &pb.InitSequenceScheduleResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), gimmickDiffTables)),
}, nil
}
func (s *GimmickServiceServer) Unlock(ctx context.Context, req *pb.UnlockRequest) (*pb.UnlockResponse, error) {
log.Printf("[GimmickService] Unlock: gimmickKeys=%d", len(req.GimmickKey))
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, item := range req.GimmickKey {
key := store.GimmickKey{
GimmickSequenceScheduleId: item.GimmickSequenceScheduleId,
GimmickSequenceId: item.GimmickSequenceId,
GimmickId: item.GimmickId,
}
unlock := user.Gimmick.Unlocks[key]
unlock.Key = key
unlock.IsUnlocked = true
user.Gimmick.Unlocks[key] = unlock
}
})
return &pb.UnlockResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserGimmickUnlock"})),
}, nil
}
+419
View File
@@ -0,0 +1,419 @@
package service
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
// listBinEntry holds path (')' as segment separator) and size from list.bin; Size is 0 when not present.
type listBinEntry struct {
Path string
Size int64
MD5 string
}
// listBinIndex caches object_id → entry for a revision.
type listBinIndex map[string]listBinEntry
type infoAlias struct {
ToName string
ToRevision string
MD5 string
}
type assetCandidate struct {
Path string
Revision string
Source string
ExpectedMD5 string
}
type listBinLoad struct {
done chan struct{}
idx listBinIndex
ok bool
}
type infoLoad struct {
done chan struct{}
m map[string]infoAlias
}
var (
listBinCache = make(map[string]listBinIndex) // revision → index
listBinInflight = make(map[string]*listBinLoad)
listBinCacheMu sync.RWMutex
infoCache = make(map[string]map[string]infoAlias) // revision → from-name → duplicate target
infoInflight = make(map[string]*infoLoad)
infoCacheMu sync.RWMutex
)
// infoJSONEntry is one entry from assets/revisions/{rev}/info.json (duplicate files: serve to-name when asked for from-name).
type infoJSONEntry struct {
FromName string `json:"from-name"`
ToName string `json:"to-name"`
ToRevision *int `json:"to-revision"`
MD5 string `json:"md5"`
}
// readVarint reads a protobuf varint from b, returns value and number of bytes consumed.
func readVarint(b []byte) (value int, n int) {
for i := 0; i < len(b) && i < 10; i++ {
value |= int(b[i]&0x7f) << (7 * i)
n = i + 1
if b[i]&0x80 == 0 {
return value, n
}
}
return 0, 0
}
func skipProtoField(wireType int, data []byte, offset int) (int, bool) {
switch wireType {
case 0:
_, n := readVarint(data[offset:])
if n == 0 {
return 0, false
}
return offset + n, true
case 1:
if offset+8 > len(data) {
return 0, false
}
return offset + 8, true
case 2:
length, n := readVarint(data[offset:])
if n == 0 || length < 0 || offset+n+length > len(data) {
return 0, false
}
return offset + n + length, true
case 5:
if offset+4 > len(data) {
return 0, false
}
return offset + 4, true
default:
return 0, false
}
}
func parseListBinEntry(data []byte) (objectId string, entry listBinEntry, ok bool) {
i := 0
for i < len(data) {
tag, n := readVarint(data[i:])
if n == 0 {
return "", listBinEntry{}, false
}
i += n
fieldNum := tag >> 3
wireType := tag & 0x7
switch fieldNum {
case 3: // path
if wireType != 2 {
return "", listBinEntry{}, false
}
length, vn := readVarint(data[i:])
if vn == 0 || length < 0 || i+vn+length > len(data) {
return "", listBinEntry{}, false
}
entry.Path = string(data[i+vn : i+vn+length])
i += vn + length
case 4: // size
if wireType != 0 {
return "", listBinEntry{}, false
}
size, vn := readVarint(data[i:])
if vn == 0 {
return "", listBinEntry{}, false
}
if size >= 256 {
entry.Size = int64(size)
}
i += vn
case 10: // md5
if wireType != 2 {
return "", listBinEntry{}, false
}
length, vn := readVarint(data[i:])
if vn == 0 || length < 0 || i+vn+length > len(data) {
return "", listBinEntry{}, false
}
entry.MD5 = string(data[i+vn : i+vn+length])
i += vn + length
case 11: // object_id
if wireType != 2 {
return "", listBinEntry{}, false
}
length, vn := readVarint(data[i:])
if vn == 0 || length <= 0 || i+vn+length > len(data) {
return "", listBinEntry{}, false
}
objectId = string(data[i+vn : i+vn+length])
i += vn + length
default:
next, ok := skipProtoField(wireType, data, i)
if !ok {
return "", listBinEntry{}, false
}
i = next
}
}
if objectId == "" || entry.Path == "" {
return "", listBinEntry{}, false
}
return objectId, entry, true
}
// parseListBin reads list.bin and builds object_id (6-byte string) → entry (path, size, md5).
// The file is a protobuf message with repeated nested entry messages, so we parse each entry
// boundary first instead of doing a flat scan across the whole file.
func parseListBin(data []byte) listBinIndex {
idx := make(listBinIndex)
i := 0
for i < len(data) {
tag, n := readVarint(data[i:])
if n == 0 {
break
}
i += n
wireType := tag & 0x7
if wireType == 2 {
length, vn := readVarint(data[i:])
if vn == 0 || length < 0 || i+vn+length > len(data) {
break
}
entryBytes := data[i+vn : i+vn+length]
objectId, entry, ok := parseListBinEntry(entryBytes)
if ok {
idx[objectId] = entry
i += vn + length
continue
}
}
next, ok := skipProtoField(wireType, data, i)
if !ok {
break
}
i = next
}
return idx
}
func loadListBinIndex(revision string) (listBinIndex, bool) {
listBinCacheMu.RLock()
idx, ok := listBinCache[revision]
listBinCacheMu.RUnlock()
if ok {
return idx, true
}
listBinCacheMu.Lock()
if idx, ok := listBinCache[revision]; ok {
listBinCacheMu.Unlock()
return idx, true
}
if load := listBinInflight[revision]; load != nil {
listBinCacheMu.Unlock()
<-load.done
return load.idx, load.ok
}
load := &listBinLoad{done: make(chan struct{})}
listBinInflight[revision] = load
listBinCacheMu.Unlock()
filePath := filepath.Join("assets", "revisions", revision, "list.bin")
data, err := os.ReadFile(filePath)
if err != nil {
listBinCacheMu.Lock()
delete(listBinInflight, revision)
close(load.done)
listBinCacheMu.Unlock()
return nil, false
}
idx = parseListBin(data)
load.idx = idx
load.ok = true
listBinCacheMu.Lock()
listBinCache[revision] = idx
delete(listBinInflight, revision)
close(load.done)
listBinCacheMu.Unlock()
return idx, true
}
func loadInfoIndex(revision string) map[string]infoAlias {
infoCacheMu.RLock()
m, ok := infoCache[revision]
infoCacheMu.RUnlock()
if ok {
return m
}
infoCacheMu.Lock()
if m, ok := infoCache[revision]; ok {
infoCacheMu.Unlock()
return m
}
if load := infoInflight[revision]; load != nil {
infoCacheMu.Unlock()
<-load.done
return load.m
}
load := &infoLoad{done: make(chan struct{})}
infoInflight[revision] = load
infoCacheMu.Unlock()
filePath := filepath.Join("assets", "revisions", revision, "info.json")
data, err := os.ReadFile(filePath)
if err != nil {
infoCacheMu.Lock()
infoCache[revision] = nil
delete(infoInflight, revision)
close(load.done)
infoCacheMu.Unlock()
return nil
}
var entries []infoJSONEntry
if err := json.Unmarshal(data, &entries); err != nil {
infoCacheMu.Lock()
infoCache[revision] = nil
delete(infoInflight, revision)
close(load.done)
infoCacheMu.Unlock()
return nil
}
m = make(map[string]infoAlias)
for _, e := range entries {
if e.FromName != "" && e.ToName != "" {
aliasRevision := revision
if e.ToRevision != nil {
aliasRevision = strconv.Itoa(*e.ToRevision)
}
m[e.FromName] = infoAlias{
ToName: e.ToName,
ToRevision: aliasRevision,
MD5: e.MD5,
}
}
}
load.m = m
infoCacheMu.Lock()
infoCache[revision] = m
delete(infoInflight, revision)
close(load.done)
infoCacheMu.Unlock()
return m
}
func pathStrToFullPaths(revision, assetType, pathStr string) []string {
fsPath := strings.ReplaceAll(pathStr, ")", "/")
if strings.Contains(fsPath, "..") || filepath.IsAbs(fsPath) || strings.HasPrefix(fsPath, "/") {
return nil
}
fsPath = filepath.Clean(fsPath)
if strings.Contains(fsPath, "..") {
return nil
}
// Prefer "global" (en) when list.bin points to ja/ko: try en first, then original.
var pathStrs []string
if strings.Contains(pathStr, ")ja)") {
pathStrs = append(pathStrs, strings.ReplaceAll(pathStr, ")ja)", ")en)"))
}
if strings.Contains(pathStr, ")ko)") {
pathStrs = append(pathStrs, strings.ReplaceAll(pathStr, ")ko)", ")en)"))
}
pathStrs = append(pathStrs, pathStr)
base := filepath.Join("assets", "revisions", revision)
var out []string
seen := make(map[string]bool)
for _, p := range pathStrs {
cleaned := filepath.Clean(strings.ReplaceAll(p, ")", "/"))
if seen[cleaned] {
continue
}
seen[cleaned] = true
switch assetType {
case "assetbundle":
out = append(out, filepath.Join(base, "assetbundle", cleaned+".assetbundle"))
case "resources":
out = append(out, filepath.Join(base, "resources", cleaned))
default:
return nil
}
}
return out
}
func appendUniqueCandidate(candidates []assetCandidate, seen map[string]bool, candidate assetCandidate) []assetCandidate {
key := candidate.Revision + ":" + candidate.Path
if seen[key] {
return candidates
}
seen[key] = true
return append(candidates, candidate)
}
func duplicateCandidatePath(candidate assetCandidate, assetType, targetRevision, targetBaseName string) string {
root := filepath.Join("assets", "revisions", candidate.Revision, assetType)
rel, err := filepath.Rel(root, candidate.Path)
if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return ""
}
return filepath.Join("assets", "revisions", targetRevision, assetType, filepath.Dir(rel), targetBaseName)
}
// objectIdToFilePathCandidates returns candidate file paths for the object: list.bin path, locale fallbacks
// (ja/ko -> en), then info.json duplicate mappings (from-name -> to-name, possibly in a different revision).
// Callers should try each path until one exists on disk.
func objectIdToFilePathCandidates(revision, assetType, objectId string) (candidates []assetCandidate, size int64, ok bool) {
idx, ok := loadListBinIndex(revision)
if !ok || idx == nil {
return nil, 0, false
}
entry, ok := idx[objectId]
if !ok || entry.Path == "" {
return nil, 0, false
}
paths := pathStrToFullPaths(revision, assetType, entry.Path)
if len(paths) == 0 {
return nil, 0, false
}
seen := make(map[string]bool)
for _, path := range paths {
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
Path: path,
Revision: revision,
Source: "list.bin",
ExpectedMD5: entry.MD5,
})
}
// Add paths from info.json: when requested file is a "from-name" (duplicate not included), serve "to-name" instead.
infoIndex := loadInfoIndex(revision)
if len(infoIndex) > 0 {
for _, c := range candidates {
alias, ok := infoIndex[filepath.Base(c.Path)]
if !ok || alias.ToName == "" {
continue
}
alt := duplicateCandidatePath(c, assetType, alias.ToRevision, alias.ToName)
if alt == "" {
continue
}
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
Path: alt,
Revision: alias.ToRevision,
Source: "info.json redirect",
ExpectedMD5: alias.MD5,
})
}
}
return candidates, entry.Size, true
}
+72
View File
@@ -0,0 +1,72 @@
package service
import (
"context"
"fmt"
"log"
"time"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
type LoginBonusServiceServer struct {
pb.UnimplementedLoginBonusServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.LoginBonusCatalog
}
func NewLoginBonusServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.LoginBonusCatalog) *LoginBonusServiceServer {
return &LoginBonusServiceServer{users: users, sessions: sessions, catalog: catalog}
}
func (s *LoginBonusServiceServer) ReceiveStamp(ctx context.Context, req *emptypb.Empty) (*pb.ReceiveStampResponse, error) {
log.Printf("[LoginBonusService] ReceiveStamp")
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
now := gametime.NowMillis()
nextStamp := user.LoginBonus.CurrentStampNumber + 1
reward, ok := s.catalog.LookupStampReward(
user.LoginBonus.LoginBonusId,
user.LoginBonus.CurrentPageNumber,
nextStamp,
)
if !ok {
log.Fatalf("[LoginBonusService] no reward found for bonusId=%d page=%d stamp=%d",
user.LoginBonus.LoginBonusId, user.LoginBonus.CurrentPageNumber, nextStamp)
}
log.Printf("[LoginBonusService] stamp %d -> possType=%d possId=%d count=%d (-> gift box)",
nextStamp, reward.PossessionType, reward.PossessionId, reward.Count)
user.Gifts.NotReceived = append(user.Gifts.NotReceived, store.NotReceivedGiftState{
GiftCommon: store.GiftCommonState{
PossessionType: reward.PossessionType,
PossessionId: reward.PossessionId,
Count: reward.Count,
GrantDatetime: now,
},
ExpirationDatetime: now + int64(30*24*time.Hour/time.Millisecond),
UserGiftUuid: fmt.Sprintf("login-bonus-%d-%d", userId, nextStamp),
})
user.Notifications.GiftNotReceiveCount = int32(len(user.Gifts.NotReceived))
user.LoginBonus.CurrentStampNumber = nextStamp
user.LoginBonus.LatestRewardReceiveDatetime = now
user.LoginBonus.LatestVersion = now
})
diff := userdata.BuildDiffFromTables(userdata.SelectTables(
userdata.FullClientTableMap(user),
[]string{"IUserLoginBonus"},
))
setCommonResponseTrailers(ctx, diff, false)
return &pb.ReceiveStampResponse{DiffUserData: diff}, nil
}
+80
View File
@@ -0,0 +1,80 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
var materialDiffTables = []string{
"IUserMaterial",
"IUserConsumableItem",
}
type MaterialServiceServer struct {
pb.UnimplementedMaterialServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.MaterialCatalog
config *masterdata.GameConfig
}
func NewMaterialServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.MaterialCatalog, config *masterdata.GameConfig) *MaterialServiceServer {
return &MaterialServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
}
func (s *MaterialServiceServer) Sell(ctx context.Context, req *pb.MaterialSellRequest) (*pb.MaterialSellResponse, error) {
log.Printf("[MaterialService] Sell: %d item(s)", len(req.MaterialPossession))
userId := currentUserId(ctx, s.users, s.sessions)
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"})
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
totalGold := int32(0)
for _, item := range req.MaterialPossession {
mat, ok := s.catalog.All[item.MaterialId]
if !ok {
log.Printf("[MaterialService] Sell: unknown materialId=%d, skipping", item.MaterialId)
continue
}
cur := user.Materials[item.MaterialId]
if cur < item.Count {
log.Printf("[MaterialService] Sell: insufficient materialId=%d have=%d need=%d", item.MaterialId, cur, item.Count)
continue
}
user.Materials[item.MaterialId] -= item.Count
if user.Materials[item.MaterialId] <= 0 {
delete(user.Materials, item.MaterialId)
}
gold := mat.SellPrice * item.Count
totalGold += gold
log.Printf("[MaterialService] Sell: materialId=%d x%d -> %d gold", item.MaterialId, item.Count, gold)
}
if totalGold > 0 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold
log.Printf("[MaterialService] Sell: total gold +%d", totalGold)
}
})
if err != nil {
return nil, fmt.Errorf("material sell: %w", err)
}
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), materialDiffTables)
diff := tracker.Apply(snapshot, tables)
return &pb.MaterialSellResponse{
DiffUserData: diff,
}, nil
}
+38
View File
@@ -0,0 +1,38 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type MissionServiceServer struct {
pb.UnimplementedMissionServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewMissionServiceServer(users store.UserRepository, sessions store.SessionRepository) *MissionServiceServer {
return &MissionServiceServer{users: users, sessions: sessions}
}
func (s *MissionServiceServer) UpdateMissionProgress(ctx context.Context, req *pb.UpdateMissionProgressRequest) (*pb.UpdateMissionProgressResponse, error) {
log.Printf("[MissionService] UpdateMissionProgress: cage=%v pictureBook=%v", req.CageMeasurableValues, req.PictureBookMeasurableValues)
userId := currentUserId(ctx, s.users, s.sessions)
snapshot, err := s.users.SnapshotUser(userId)
if err != nil {
return nil, fmt.Errorf("snapshot user: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserMission"}))
return &pb.UpdateMissionProgressResponse{
DiffUserData: diff,
}, nil
}
+45
View File
@@ -0,0 +1,45 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type MovieServiceServer struct {
pb.UnimplementedMovieServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewMovieServiceServer(users store.UserRepository, sessions store.SessionRepository) *MovieServiceServer {
return &MovieServiceServer{users: users, sessions: sessions}
}
func (s *MovieServiceServer) SaveViewedMovie(ctx context.Context, req *pb.SaveViewedMovieRequest) (*pb.SaveViewedMovieResponse, error) {
log.Printf("[MovieService] SaveViewedMovie: movieIds=%v", req.MovieId)
userId := currentUserId(ctx, s.users, s.sessions)
now := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, mid := range req.MovieId {
user.ViewedMovies[mid] = now
}
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserMovie"}))
return &pb.SaveViewedMovieResponse{
DiffUserData: diff,
}, nil
}
+40
View File
@@ -0,0 +1,40 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type NaviCutInServiceServer struct {
pb.UnimplementedNaviCutInServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewNaviCutInServiceServer(users store.UserRepository, sessions store.SessionRepository) *NaviCutInServiceServer {
return &NaviCutInServiceServer{users: users, sessions: sessions}
}
func (s *NaviCutInServiceServer) RegisterPlayed(ctx context.Context, req *pb.RegisterPlayedRequest) (*pb.RegisterPlayedResponse, error) {
log.Printf("[NaviCutInService] RegisterPlayed: naviCutId=%d", req.NaviCutId)
userId := currentUserId(ctx, s.users, s.sessions)
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
user.NaviCutInPlayed[req.NaviCutId] = true
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserNaviCutIn"}))
return &pb.RegisterPlayedResponse{
DiffUserData: diff,
}, nil
}
+42
View File
@@ -0,0 +1,42 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
type NotificationServiceServer struct {
pb.UnimplementedNotificationServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewNotificationServiceServer(users store.UserRepository, sessions store.SessionRepository) *NotificationServiceServer {
return &NotificationServiceServer{users: users, sessions: sessions}
}
func (s *NotificationServiceServer) GetHeaderNotification(ctx context.Context, req *emptypb.Empty) (*pb.GetHeaderNotificationResponse, error) {
log.Printf("[NotificationService] GetHeaderNotification")
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return &pb.GetHeaderNotificationResponse{
GiftNotReceiveCount: 0,
FriendRequestReceiveCount: 0,
IsExistUnreadInformation: false,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
return &pb.GetHeaderNotificationResponse{
GiftNotReceiveCount: int32(len(user.Gifts.NotReceived)),
FriendRequestReceiveCount: user.Notifications.FriendRequestReceiveCount,
IsExistUnreadInformation: user.Notifications.IsExistUnreadInformation,
DiffUserData: userdata.EmptyDiff(),
}, nil
}
+457
View File
@@ -0,0 +1,457 @@
package service
import (
"bytes"
"crypto/md5"
"encoding/hex"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
const termsVersionMarker = "###123###"
const privacyVersionMarker = "###123###"
const informationPage = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lunar Tear</title>
<style>
body { margin:0; padding:40px 20px; font-family:"Noto Sans",sans-serif;
background:#0a0a0f; color:#d4cfc6; text-align:center; }
h1 { font-size:1.4em; letter-spacing:.15em; color:#e8e0d0; margin-bottom:4px; }
.sub { font-size:.75em; color:#888; margin-bottom:32px; }
.sep { width:60px; border:none; border-top:1px solid #333; margin:24px auto; }
p { font-size:.85em; line-height:1.6; color:#999; max-width:360px; margin:0 auto 12px; }
a { color:#a0c4e8; text-decoration:none; }
</style>
</head>
<body>
<h1>LUNAR TEAR</h1>
<div class="sub">Private Preservation Server</div>
<hr class="sep">
<p>A community effort to keep NieR Re[in]carnation playable after official service ended.</p>
<p>This server is not affiliated with or endorsed by SQUARE ENIX or Applibot.</p>
<hr class="sep">
<p style="font-size:.7em;color:#666;">&copy; SQUARE ENIX / Applibot — All game assets belong to their respective owners.</p>
</body>
</html>`
// resourcesURLOriginal is the base URL embedded in list.bin; must be replaced with same-length (43 bytes) when rewriting.
const resourcesURLOriginal = "https://resources.app.nierreincarnation.com"
type OctoHTTPServer struct {
mux *http.ServeMux
ResourcesBaseURL string // if non-empty and exactly 43 chars, list.bin is rewritten to use this base for asset URLs
revisions *revisionTracker
resolver *assetResolver
}
func staticPageLanguage(path string) string {
parts := strings.Split(path, "/")
for i := 0; i+1 < len(parts); i++ {
if parts[i] == "static" && parts[i+1] != "" {
return parts[i+1]
}
}
return "unknown"
}
func renderStaticTermsPage(title, language, version string) string {
return "<html><head><title>" + title + "</title></head><body><h1>" + title +
"</h1><p>Language: " + language + "</p><p>Version: " + version + "</p></body></html>"
}
// countResponseWriter wraps http.ResponseWriter and counts bytes written.
type countResponseWriter struct {
http.ResponseWriter
n int64
}
type fileMD5Entry struct {
size int64
modTime int64
md5 string
}
var (
fileMD5Cache = make(map[string]fileMD5Entry)
fileMD5CacheMu sync.RWMutex
)
func (c *countResponseWriter) Write(p []byte) (int, error) {
n, err := c.ResponseWriter.Write(p)
c.n += int64(n)
return n, err
}
func fileMD5Hex(path string, info os.FileInfo) (string, error) {
modTime := info.ModTime().UnixNano()
fileMD5CacheMu.RLock()
cached, ok := fileMD5Cache[path]
fileMD5CacheMu.RUnlock()
if ok && cached.size == info.Size() && cached.modTime == modTime {
return cached.md5, nil
}
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := md5.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
sum := hex.EncodeToString(h.Sum(nil))
fileMD5CacheMu.Lock()
fileMD5Cache[path] = fileMD5Entry{
size: info.Size(),
modTime: modTime,
md5: sum,
}
fileMD5CacheMu.Unlock()
return sum, nil
}
func NewOctoHTTPServer(resourcesBaseURL string) *OctoHTTPServer {
s := &OctoHTTPServer{
mux: http.NewServeMux(),
ResourcesBaseURL: resourcesBaseURL,
revisions: newRevisionTracker(),
resolver: newAssetResolver(),
}
s.mux.HandleFunc("/", s.handleAll)
return s
}
func (s *OctoHTTPServer) Handler() http.Handler {
return s.mux
}
func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
isAssetRequest := strings.Contains(path, "/unso-")
isMasterDataRequest := strings.Contains(path, "/assets/release/") && strings.Contains(path, "database.bin")
if !isAssetRequest && !isMasterDataRequest {
log.Printf("[HTTP] %s %s (Host: %s)", r.Method, r.URL.String(), r.Host)
for k, v := range r.Header {
log.Printf("[HTTP] %s: %s", k, v)
}
}
// Octo v2 API — asset bundle management
if strings.HasPrefix(path, "/v2/") {
s.handleOctoV2(w, r, path)
return
}
// Octo v1 list: /v1/list/{version}/{revision} — same list.bin as v2, keyed by revision
if strings.HasPrefix(path, "/v1/list/") {
s.serveOctoV1List(w, r, path)
return
}
// Game web API requests
if strings.Contains(path, "/web/") || strings.Contains(r.Host, "web.app.nierreincarnation") {
s.handleWebAPI(w, r, path)
return
}
// Master data download (should not be reached if version matches)
if strings.HasPrefix(path, "/master-data/") {
log.Printf("[HTTP] Master data request for path: %s — returning empty", path)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", "0")
w.WriteHeader(200)
return
}
// /assets/release/{version}/database.bin.e — master data (HEAD/GET), same as MariesWonderland
if strings.Contains(path, "/assets/release/") && strings.Contains(path, "database.bin") {
s.serveDatabaseBinE(w, r, path)
return
}
// Asset bundle requests (from list.bin URLs: .../unso-{v}-{type}/{o}?generation=...&alt=media)
if strings.Contains(path, "/unso-") {
s.serveUnsoAsset(w, r, path)
return
}
// In-game information / news page
if strings.Contains(path, "/information") {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
w.Write([]byte(informationPage))
return
}
// Log request body for debugging Octo protocol
if r.Body != nil {
body := make([]byte, 4096)
n, _ := r.Body.Read(body)
if n > 0 {
log.Printf("[HTTP] body (%d bytes): %x", n, body[:n])
if n < 256 {
log.Printf("[HTTP] body (ascii): %s", string(body[:n]))
}
}
}
log.Printf("[HTTP] >>> UNHANDLED REQUEST: %s %s — returning empty 200", r.Method, path)
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(200)
w.Write([]byte{})
}
func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path string) {
log.Printf("[OctoV2] %s %s", r.Method, path)
// /v2/pub/a/{appId}/v/{version}/list/{offset} — resource listing
if strings.Contains(path, "/list/") {
parts := strings.Split(path, "/")
if len(parts) > 0 {
requestedRevision := parts[len(parts)-1]
if requestedRevision != "" {
revision := "0"
filePath := "assets/revisions/0/list.bin"
if requestedRevision != revision {
log.Printf("[OctoV2] Resource list request revision=%s canonicalized to revision=%s", requestedRevision, revision)
}
log.Printf("[OctoV2] Resource list request — serving %s (requested_revision=%s canonical_revision=%s)", filePath, requestedRevision, revision)
s.revisions.Remember(r.RemoteAddr, revision)
go s.resolver.Prewarm(revision)
s.serveListBin(w, filePath)
return
}
}
log.Printf("[OctoV2] Resource list request without revision segment — returning empty protobuf")
w.Header().Set("Content-Type", "application/x-protobuf")
w.WriteHeader(http.StatusOK)
return
}
// /v2/pub/a/{appId}/v/{version}/info — DB info
if strings.Contains(path, "/info") {
log.Printf("[OctoV2] Info request — returning empty protobuf")
w.Header().Set("Content-Type", "application/x-protobuf")
w.WriteHeader(200)
return
}
log.Printf("[OctoV2] Unknown endpoint: %s — returning empty protobuf", path)
w.Header().Set("Content-Type", "application/x-protobuf")
w.WriteHeader(200)
}
// serveOctoV1List handles GET /v1/list/{version}/{revision} — serves assets/revisions/{revision}/list.bin.
func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, path string) {
parts := strings.Split(strings.Trim(path, "/"), "/")
// ["v1", "list", "300116832", "0"] -> revision = last segment
requestedRevision := "0"
if len(parts) >= 4 {
requestedRevision = parts[len(parts)-1]
}
revision := "0"
filePath := "assets/revisions/0/list.bin"
if requestedRevision != revision {
log.Printf("[OctoV1] list request revision=%s canonicalized to revision=%s", requestedRevision, revision)
}
log.Printf("[OctoV1] %s %s — serving %s (requested_revision=%s canonical_revision=%s)", r.Method, path, filePath, requestedRevision, revision)
s.revisions.Remember(r.RemoteAddr, revision)
go s.resolver.Prewarm(revision)
s.serveListBin(w, filePath)
}
// serveUnsoAsset serves asset bundle or resource for URLs like /resource-bundle-server/unso-{version}-{type}/{object_id}.
func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, path string) {
parts := strings.Split(strings.Trim(path, "/"), "/")
var segment, objectId string
for i, p := range parts {
if strings.HasPrefix(p, "unso-") && i+1 < len(parts) {
segment = p
objectId = parts[i+1]
break
}
}
if segment == "" || objectId == "" {
log.Printf("[HTTP] Asset request malformed: %s", path)
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusNotFound)
return
}
// segment = "unso-200116832-assetbundle" -> type = last part after "-"
segParts := strings.Split(segment, "-")
if len(segParts) < 2 {
log.Printf("[HTTP] Asset request segment malformed: %s", segment)
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusNotFound)
return
}
assetType := segParts[len(segParts)-1] // "assetbundle" or "resources"
if assetType != "assetbundle" && assetType != "resources" {
log.Printf("[HTTP] Asset request unknown type: %s", assetType)
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusNotFound)
return
}
activeRevision := s.revisions.Active(r.RemoteAddr)
resolution, ok := s.resolver.Resolve(objectId, assetType, activeRevision)
if !ok {
log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s) no candidates", path, objectId, assetType, activeRevision)
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusNotFound)
return
}
baseDir := filepath.Join("assets", "revisions")
var triedPaths []string
var md5Mismatches []string
for _, candidate := range resolution.Candidates {
rel, err := filepath.Rel(baseDir, candidate.Path)
if err != nil || strings.Contains(rel, "..") || filepath.IsAbs(rel) {
continue
}
triedPaths = append(triedPaths, candidate.Revision+":"+candidate.Path+" ["+candidate.Source+"]")
f, err := os.Open(candidate.Path)
if err != nil {
continue
}
info, err := f.Stat()
if err != nil {
f.Close()
continue
}
if info.IsDir() {
f.Close()
continue
}
// Only validate size when list.bin gave a plausible file size (>= 256); small values are often wrong (e.g. different proto field).
if resolution.ListSize >= 256 && info.Size() != resolution.ListSize {
f.Close()
continue
}
if candidate.ExpectedMD5 != "" {
actualMD5, err := fileMD5Hex(candidate.Path, info)
if err != nil {
log.Printf("[HTTP] Asset md5 read failed: %s err=%v", candidate.Path, err)
f.Close()
continue
}
if !strings.EqualFold(actualMD5, candidate.ExpectedMD5) {
md5Mismatches = append(md5Mismatches, candidate.Revision+":"+candidate.Path+" ["+candidate.Source+"] expected="+candidate.ExpectedMD5+" actual="+actualMD5)
log.Printf("[HTTP] Asset md5 mismatch: object_id=%s type=%s path=%s expected=%s actual=%s active_revision=%s list_revision=%s resolved_revision=%s source=%s", objectId, assetType, candidate.Path, candidate.ExpectedMD5, actualMD5, resolution.ActiveRevision, resolution.ListRevision, candidate.Revision, candidate.Source)
f.Close()
continue
}
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
cw := &countResponseWriter{ResponseWriter: w}
http.ServeContent(cw, r, filepath.Base(candidate.Path), info.ModTime(), f)
return
}
if len(md5Mismatches) > 0 {
log.Printf("[HTTP] Asset md5 mismatches: object_id=%s type=%s active_revision=%s list_revision=%s mismatches=%v", objectId, assetType, resolution.ActiveRevision, resolution.ListRevision, md5Mismatches)
}
log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s list_revision=%s) tried paths: %v", path, objectId, assetType, resolution.ActiveRevision, resolution.ListRevision, triedPaths)
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusNotFound)
}
// serveListBin reads list.bin from filePath, optionally rewrites the resource base URL to s.ResourcesBaseURL
// (must be exactly 43 bytes to preserve protobuf layout), and writes the result to w.
func (s *OctoHTTPServer) serveListBin(w http.ResponseWriter, filePath string) {
data, err := os.ReadFile(filePath)
if err != nil {
log.Printf("[Octo] list.bin read error: %v", err)
http.Error(w, "list not found", http.StatusNotFound)
return
}
orig := []byte(resourcesURLOriginal)
if s.ResourcesBaseURL != "" {
if len(s.ResourcesBaseURL) != len(orig) {
log.Printf("[Octo] resources-base-url length is %d, need %d — serving list.bin unchanged", len(s.ResourcesBaseURL), len(orig))
} else {
repl := []byte(s.ResourcesBaseURL)
if idx := bytes.Index(data, orig); idx >= 0 {
copy(data[idx:], repl)
log.Printf("[Octo] list.bin: rewrote resource base URL to %s", s.ResourcesBaseURL)
}
}
}
w.Header().Set("Content-Type", "application/x-protobuf")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.WriteHeader(http.StatusOK)
w.Write(data)
}
// serveDatabaseBinE serves MasterMemory database: /assets/release/{version}/database.bin.e
// -> assets/release/{version}.bin.e (or assets/release/database.bin.e fallback).
func (s *OctoHTTPServer) serveDatabaseBinE(w http.ResponseWriter, r *http.Request, path string) {
parts := strings.Split(path, "/")
var version string
for i, p := range parts {
if p == "release" && i+1 < len(parts) {
version = parts[i+1]
break
}
}
filePath := "assets/release/database.bin.e"
if version != "" {
vPath := "assets/release/" + version + ".bin.e"
if _, err := os.Stat(vPath); err == nil {
filePath = vPath
}
}
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeFile(w, r, filePath)
}
func (s *OctoHTTPServer) handleWebAPI(w http.ResponseWriter, r *http.Request, path string) {
log.Printf("[WebAPI] Serving: %s", path)
if strings.Contains(path, "database.bin") {
s.serveDatabaseBinE(w, r, path)
return
}
if strings.Contains(path, "termsofuse") {
language := staticPageLanguage(path)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(200)
w.Write([]byte(renderStaticTermsPage("Terms of Service", language, termsVersionMarker)))
return
}
if strings.Contains(path, "privacy") {
language := staticPageLanguage(path)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
w.Write([]byte(renderStaticTermsPage("Privacy Policy", language, privacyVersionMarker)))
return
}
if strings.Contains(path, "maintenance") {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
w.Write([]byte(`<!DOCTYPE html><html><body></body></html>`))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
w.Write([]byte(`<!DOCTYPE html><html><body></body></html>`))
}
+47
View File
@@ -0,0 +1,47 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type OmikujiServiceServer struct {
pb.UnimplementedOmikujiServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.OmikujiCatalog
}
func NewOmikujiServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.OmikujiCatalog) *OmikujiServiceServer {
return &OmikujiServiceServer{users: users, sessions: sessions, catalog: catalog}
}
func (s *OmikujiServiceServer) OmikujiDraw(ctx context.Context, req *pb.OmikujiDrawRequest) (*pb.OmikujiDrawResponse, error) {
log.Printf("[OmikujiService] OmikujiDraw: omikujiId=%d", req.OmikujiId)
userId := currentUserId(ctx, s.users, s.sessions)
now := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
user.DrawnOmikuji[req.OmikujiId] = now
})
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserOmikuji"}))
return &pb.OmikujiDrawResponse{
OmikujiResultAssetId: s.catalog.LookupAssetId(req.OmikujiId),
OmikujiItem: []*pb.OmikujiItem{},
DiffUserData: diff,
}, nil
}
+196
View File
@@ -0,0 +1,196 @@
package service
import (
"context"
"fmt"
"log"
"math/rand"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
const partsMaxLevel = int32(15)
var partsDiffTables = []string{
"IUserParts",
"IUserConsumableItem",
}
type PartsServiceServer struct {
pb.UnimplementedPartsServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.PartsCatalog
config *masterdata.GameConfig
}
func NewPartsServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.PartsCatalog, config *masterdata.GameConfig) *PartsServiceServer {
return &PartsServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
}
func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest) (*pb.PartsSellResponse, error) {
log.Printf("[PartsService] Sell: %d part(s)", len(req.UserPartsUuid))
userId := currentUserId(ctx, s.users, s.sessions)
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserParts", oldUser, userdata.SortedPartsRecords, []string{"userId", "userPartsUuid"})
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
totalGold := int32(0)
for _, uuid := range req.UserPartsUuid {
part, ok := user.Parts[uuid]
if !ok {
log.Printf("[PartsService] Sell: uuid=%s not found, skipping", uuid)
continue
}
if part.IsProtected {
log.Printf("[PartsService] Sell: uuid=%s is protected, skipping", uuid)
continue
}
partDef, ok := s.catalog.PartsById[part.PartsId]
if !ok {
log.Printf("[PartsService] Sell: partsId=%d not in catalog, skipping", part.PartsId)
continue
}
sellFunc, ok := s.catalog.SellPriceByRarity[partDef.RarityType]
if !ok {
log.Printf("[PartsService] Sell: no sell price func for rarity=%d, skipping", partDef.RarityType)
continue
}
gold := sellFunc.Evaluate(part.Level)
totalGold += gold
delete(user.Parts, uuid)
log.Printf("[PartsService] Sell: uuid=%s partsId=%d level=%d -> %d gold", uuid, part.PartsId, part.Level, gold)
}
if totalGold > 0 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold
log.Printf("[PartsService] Sell: total gold +%d", totalGold)
}
})
if err != nil {
return nil, fmt.Errorf("parts sell: %w", err)
}
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), partsDiffTables)
diff := tracker.Apply(snapshot, tables)
return &pb.PartsSellResponse{
DiffUserData: diff,
}, nil
}
func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRequest) (*pb.PartsEnhanceResponse, error) {
log.Printf("[PartsService] Enhance: uuid=%s", req.UserPartsUuid)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
isSuccess := false
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
part, ok := user.Parts[req.UserPartsUuid]
if !ok {
log.Printf("[PartsService] Enhance: part uuid=%s not found", req.UserPartsUuid)
return
}
if part.Level >= partsMaxLevel {
log.Printf("[PartsService] Enhance: part uuid=%s already at max level %d", req.UserPartsUuid, part.Level)
return
}
partDef, ok := s.catalog.PartsById[part.PartsId]
if !ok {
log.Printf("[PartsService] Enhance: part master id=%d not found", part.PartsId)
return
}
rarity, ok := s.catalog.RarityByRarityType[partDef.RarityType]
if !ok {
log.Printf("[PartsService] Enhance: rarity type=%d not found", partDef.RarityType)
return
}
goldCost := int32(0)
if prices, ok := s.catalog.PriceByGroupAndLevel[rarity.PartsLevelUpPriceGroupId]; ok {
goldCost = prices[part.Level]
}
currentGold := user.ConsumableItems[s.config.ConsumableItemIdForGold]
if currentGold < goldCost {
log.Printf("[PartsService] Enhance: insufficient gold have=%d need=%d", currentGold, goldCost)
return
}
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
successRate := int32(1000)
if rates, ok := s.catalog.RateByGroupAndLevel[rarity.PartsLevelUpRateGroupId]; ok {
if r, ok := rates[part.Level]; ok {
successRate = r
}
}
if rand.Intn(1000) < int(successRate) {
part.Level++
isSuccess = true
log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰, cost=%d gold)",
part.PartsId, part.Level-1, part.Level, successRate, goldCost)
} else {
log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰, cost=%d gold)",
part.PartsId, part.Level, successRate, goldCost)
}
part.LatestVersion = nowMillis
user.Parts[req.UserPartsUuid] = part
})
if err != nil {
return nil, fmt.Errorf("parts enhance: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, partsDiffTables))
return &pb.PartsEnhanceResponse{
IsSuccess: isSuccess,
DiffUserData: diff,
}, nil
}
func (s *PartsServiceServer) ReplacePreset(ctx context.Context, req *pb.PartsReplacePresetRequest) (*pb.PartsReplacePresetResponse, error) {
log.Printf("[PartsService] ReplacePreset: preset=%d uuids=[%s, %s, %s]",
req.UserPartsPresetNumber, req.UserPartsUuid01, req.UserPartsUuid02, req.UserPartsUuid03)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
preset := user.PartsPresets[req.UserPartsPresetNumber]
preset.UserPartsPresetNumber = req.UserPartsPresetNumber
preset.UserPartsUuid01 = req.UserPartsUuid01
preset.UserPartsUuid02 = req.UserPartsUuid02
preset.UserPartsUuid03 = req.UserPartsUuid03
preset.LatestVersion = nowMillis
user.PartsPresets[req.UserPartsPresetNumber] = preset
})
if err != nil {
return nil, fmt.Errorf("parts replace preset: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserPartsPreset"}))
return &pb.PartsReplacePresetResponse{
DiffUserData: diff,
}, nil
}
+40
View File
@@ -0,0 +1,40 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type PortalCageServiceServer struct {
pb.UnimplementedPortalCageServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewPortalCageServiceServer(users store.UserRepository, sessions store.SessionRepository) *PortalCageServiceServer {
return &PortalCageServiceServer{users: users, sessions: sessions}
}
func (s *PortalCageServiceServer) UpdatePortalCageSceneProgress(ctx context.Context, req *pb.UpdatePortalCageSceneProgressRequest) (*pb.UpdatePortalCageSceneProgressResponse, error) {
log.Printf("[PortalCageService] UpdatePortalCageSceneProgress: portalCageSceneId=%d", req.PortalCageSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
now := gametime.NowMillis()
user.PortalCageStatus.IsCurrentProgress = true
user.PortalCageStatus.LatestVersion = now
})
tables := userdata.SelectTables(
userdata.FullClientTableMap(user),
[]string{"IUserPortalCageStatus"},
)
return &pb.UpdatePortalCageSceneProgressResponse{
DiffUserData: userdata.BuildDiffFromTablesOrdered(tables, []string{"IUserPortalCageStatus"}),
}, nil
}
+403
View File
@@ -0,0 +1,403 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
type BigHuntServiceServer struct {
pb.UnimplementedBigHuntServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.BigHuntCatalog
engine *questflow.QuestHandler
}
func NewBigHuntServiceServer(
users store.UserRepository,
sessions store.SessionRepository,
catalog *masterdata.BigHuntCatalog,
engine *questflow.QuestHandler,
) *BigHuntServiceServer {
return &BigHuntServiceServer{users: users, sessions: sessions, catalog: catalog, engine: engine}
}
var bigHuntDiffTables = []string{
"IUserBigHuntProgressStatus",
"IUserBigHuntMaxScore",
"IUserBigHuntStatus",
"IUserBigHuntScheduleMaxScore",
"IUserBigHuntWeeklyMaxScore",
"IUserBigHuntWeeklyStatus",
}
func buildBigHuntDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData {
tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames)
return userdata.BuildDiffFromTablesOrdered(tables, tableNames)
}
func (s *BigHuntServiceServer) StartBigHuntQuest(ctx context.Context, req *pb.StartBigHuntQuestRequest) (*pb.StartBigHuntQuestResponse, error) {
log.Printf("[BigHuntService] StartBigHuntQuest: bossQuestId=%d questId=%d deckNumber=%d isDryRun=%v",
req.BigHuntBossQuestId, req.BigHuntQuestId, req.UserDeckNumber, req.IsDryRun)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
bhQuest, ok := s.catalog.QuestById[req.BigHuntQuestId]
if !ok {
log.Printf("[BigHuntService] StartBigHuntQuest: unknown bigHuntQuestId=%d", req.BigHuntQuestId)
}
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
if ok {
s.engine.HandleBigHuntQuestStart(user, bhQuest.QuestId, req.UserDeckNumber, nowMillis)
}
user.BigHuntProgress = store.BigHuntProgress{
CurrentBigHuntBossQuestId: req.BigHuntBossQuestId,
CurrentBigHuntQuestId: req.BigHuntQuestId,
CurrentQuestSceneId: 0,
IsDryRun: req.IsDryRun,
LatestVersion: nowMillis,
}
user.BigHuntDeckNumber = req.UserDeckNumber
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
st.DailyChallengeCount++
st.LatestChallengeDatetime = nowMillis
st.LatestVersion = nowMillis
user.BigHuntStatuses[req.BigHuntBossQuestId] = st
})
return &pb.StartBigHuntQuestResponse{
DiffUserData: buildBigHuntDiff(user, append([]string{"IUserQuest"}, bigHuntDiffTables...)),
}, nil
}
func (s *BigHuntServiceServer) UpdateBigHuntQuestSceneProgress(ctx context.Context, req *pb.UpdateBigHuntQuestSceneProgressRequest) (*pb.UpdateBigHuntQuestSceneProgressResponse, error) {
log.Printf("[BigHuntService] UpdateBigHuntQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.BigHuntProgress.CurrentQuestSceneId = req.QuestSceneId
user.BigHuntProgress.LatestVersion = nowMillis
})
return &pb.UpdateBigHuntQuestSceneProgressResponse{
DiffUserData: buildBigHuntDiff(user, []string{"IUserBigHuntProgressStatus"}),
}, nil
}
func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.FinishBigHuntQuestRequest) (*pb.FinishBigHuntQuestResponse, error) {
log.Printf("[BigHuntService] FinishBigHuntQuest: bossQuestId=%d questId=%d isRetired=%v",
req.BigHuntBossQuestId, req.BigHuntQuestId, req.IsRetired)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
bhQuest := s.catalog.QuestById[req.BigHuntQuestId]
bossQuest := s.catalog.BossQuestById[req.BigHuntBossQuestId]
boss := s.catalog.BossByBossId[bossQuest.BigHuntBossId]
var scoreInfo *pb.BigHuntScoreInfo
var scoreRewards []*pb.BigHuntReward
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleBigHuntQuestFinish(user, bhQuest.QuestId, req.IsRetired, false, nowMillis)
if req.IsRetired || user.BigHuntProgress.IsDryRun {
user.BigHuntProgress = store.BigHuntProgress{LatestVersion: nowMillis}
return
}
detail := user.BigHuntBattleDetail
totalDamage := detail.TotalDamage
baseScore := totalDamage
difficultyBonusPermil := int32(0)
if coeff, ok := s.catalog.ScoreCoefficients[bhQuest.BigHuntQuestScoreCoefficientId]; ok {
difficultyBonusPermil = coeff
}
aliveBonusPermil := int32(500)
maxComboBonusPermil := int32(0)
if detail.MaxComboCount >= 100 {
maxComboBonusPermil = 300
} else if detail.MaxComboCount >= 50 {
maxComboBonusPermil = 200
} else if detail.MaxComboCount >= 20 {
maxComboBonusPermil = 100
}
userScore := baseScore * int64(1000+difficultyBonusPermil+aliveBonusPermil+maxComboBonusPermil) / 1000
isHighScore := false
oldMaxBoss := user.BigHuntMaxScores[bossQuest.BigHuntBossId]
oldMax := oldMaxBoss.MaxScore
if userScore > oldMax {
isHighScore = true
user.BigHuntMaxScores[bossQuest.BigHuntBossId] = store.BigHuntMaxScore{
MaxScore: userScore,
MaxScoreUpdateDatetime: nowMillis,
LatestVersion: nowMillis,
}
}
schedKey := store.BigHuntScheduleScoreKey{
BigHuntScheduleId: s.catalog.ActiveScheduleId,
BigHuntBossId: bossQuest.BigHuntBossId,
}
oldSchedMax := user.BigHuntScheduleMaxScores[schedKey].MaxScore
if userScore > oldSchedMax {
user.BigHuntScheduleMaxScores[schedKey] = store.BigHuntScheduleMaxScore{
MaxScore: userScore,
MaxScoreUpdateDatetime: nowMillis,
LatestVersion: nowMillis,
}
}
weeklyVersion := gametime.WeeklyVersion(nowMillis)
weekKey := store.BigHuntWeeklyScoreKey{
BigHuntWeeklyVersion: weeklyVersion,
AttributeType: boss.AttributeType,
}
oldWeeklyMax := user.BigHuntWeeklyMaxScores[weekKey].MaxScore
if userScore > oldWeeklyMax {
user.BigHuntWeeklyMaxScores[weekKey] = store.BigHuntWeeklyMaxScore{
MaxScore: userScore,
LatestVersion: nowMillis,
}
}
assetGradeIconId := s.catalog.ResolveGradeIconId(bossQuest.BigHuntBossId, userScore)
scoreInfo = &pb.BigHuntScoreInfo{
UserScore: userScore,
IsHighScore: isHighScore,
TotalDamage: totalDamage,
BaseScore: baseScore,
DifficultyBonusPermil: difficultyBonusPermil,
AliveBonusPermil: aliveBonusPermil,
MaxComboBonusPermil: maxComboBonusPermil,
AssetGradeIconId: assetGradeIconId,
}
if isHighScore {
rewardGroupId := s.catalog.ResolveActiveScoreRewardGroupId(
bossQuest.BigHuntScoreRewardGroupScheduleId, nowMillis)
if rewardGroupId > 0 {
newItems := s.catalog.CollectNewRewards(rewardGroupId, oldMax, userScore)
for _, item := range newItems {
s.engine.Granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis)
scoreRewards = append(scoreRewards, &pb.BigHuntReward{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
Count: item.Count,
})
}
}
}
user.BigHuntProgress = store.BigHuntProgress{LatestVersion: nowMillis}
user.BigHuntBattleBinary = nil
user.BigHuntBattleDetail = store.BigHuntBattleDetail{}
})
if scoreInfo == nil {
scoreInfo = &pb.BigHuntScoreInfo{}
}
if scoreRewards == nil {
scoreRewards = []*pb.BigHuntReward{}
}
return &pb.FinishBigHuntQuestResponse{
ScoreInfo: scoreInfo,
ScoreReward: scoreRewards,
BattleReport: &pb.BigHuntBattleReport{
BattleReportWave: []*pb.BigHuntBattleReportWave{},
},
DiffUserData: buildBigHuntDiff(user, append([]string{
"IUserQuest",
"IUserConsumableItem",
"IUserMaterial",
}, bigHuntDiffTables...)),
}, nil
}
func (s *BigHuntServiceServer) RestartBigHuntQuest(ctx context.Context, req *pb.RestartBigHuntQuestRequest) (*pb.RestartBigHuntQuestResponse, error) {
log.Printf("[BigHuntService] RestartBigHuntQuest: bossQuestId=%d questId=%d", req.BigHuntBossQuestId, req.BigHuntQuestId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
bhQuest := s.catalog.QuestById[req.BigHuntQuestId]
var battleBinary []byte
var deckNumber int32
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleBigHuntQuestStart(user, bhQuest.QuestId, user.BigHuntDeckNumber, nowMillis)
user.BigHuntProgress.CurrentQuestSceneId = 0
user.BigHuntProgress.LatestVersion = nowMillis
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
st.DailyChallengeCount++
st.LatestChallengeDatetime = nowMillis
st.LatestVersion = nowMillis
user.BigHuntStatuses[req.BigHuntBossQuestId] = st
battleBinary = user.BigHuntBattleBinary
deckNumber = user.BigHuntDeckNumber
})
return &pb.RestartBigHuntQuestResponse{
BattleBinary: battleBinary,
DeckNumber: deckNumber,
DiffUserData: buildBigHuntDiff(user, append([]string{"IUserQuest"}, bigHuntDiffTables...)),
}, nil
}
func (s *BigHuntServiceServer) SkipBigHuntQuest(ctx context.Context, req *pb.SkipBigHuntQuestRequest) (*pb.SkipBigHuntQuestResponse, error) {
log.Printf("[BigHuntService] SkipBigHuntQuest: bossQuestId=%d skipCount=%d", req.BigHuntBossQuestId, req.SkipCount)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
st.DailyChallengeCount += req.SkipCount
st.LatestChallengeDatetime = nowMillis
st.LatestVersion = nowMillis
user.BigHuntStatuses[req.BigHuntBossQuestId] = st
})
return &pb.SkipBigHuntQuestResponse{
ScoreReward: []*pb.BigHuntReward{},
DiffUserData: buildBigHuntDiff(user, bigHuntDiffTables),
}, nil
}
func (s *BigHuntServiceServer) SaveBigHuntBattleInfo(ctx context.Context, req *pb.SaveBigHuntBattleInfoRequest) (*pb.SaveBigHuntBattleInfoResponse, error) {
log.Printf("[BigHuntService] SaveBigHuntBattleInfo: elapsedFrames=%d", req.ElapsedFrameCount)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
var totalDamage int64
if req.BigHuntBattleDetail != nil {
for _, ci := range req.BigHuntBattleDetail.CostumeBattleInfo {
if ci != nil {
totalDamage += ci.TotalDamage
}
}
}
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.BigHuntBattleBinary = req.BattleBinary
if req.BigHuntBattleDetail != nil {
user.BigHuntBattleDetail = store.BigHuntBattleDetail{
DeckType: req.BigHuntBattleDetail.DeckType,
UserTripleDeckNumber: req.BigHuntBattleDetail.UserTripleDeckNumber,
BossKnockDownCount: req.BigHuntBattleDetail.BossKnockDownCount,
MaxComboCount: req.BigHuntBattleDetail.MaxComboCount,
TotalDamage: totalDamage,
}
}
user.BigHuntProgress.LatestVersion = nowMillis
})
return &pb.SaveBigHuntBattleInfoResponse{
DiffUserData: buildBigHuntDiff(user, []string{"IUserBigHuntProgressStatus"}),
}, nil
}
func (s *BigHuntServiceServer) GetBigHuntTopData(ctx context.Context, _ *emptypb.Empty) (*pb.GetBigHuntTopDataResponse, error) {
log.Printf("[BigHuntService] GetBigHuntTopData")
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.SnapshotUser(userId)
nowMillis := gametime.NowMillis()
weeklyVersion := gametime.WeeklyVersion(nowMillis)
var weeklyScoreResults []*pb.WeeklyScoreResult
for _, boss := range s.catalog.BossByBossId {
key := store.BigHuntWeeklyScoreKey{
BigHuntWeeklyVersion: weeklyVersion,
AttributeType: boss.AttributeType,
}
ws := user.BigHuntWeeklyMaxScores[key]
gradeIconId := s.catalog.ResolveGradeIconId(boss.BigHuntBossId, ws.MaxScore)
weeklyScoreResults = append(weeklyScoreResults, &pb.WeeklyScoreResult{
AttributeType: boss.AttributeType,
BeforeMaxScore: ws.MaxScore,
CurrentMaxScore: ws.MaxScore,
BeforeAssetGradeIconId: gradeIconId,
CurrentAssetGradeIconId: gradeIconId,
AfterMaxScore: ws.MaxScore,
AfterAssetGradeIconId: gradeIconId,
})
}
ws := user.BigHuntWeeklyStatuses[weeklyVersion]
weeklyRewards := s.resolveWeeklyRewards(user, weeklyVersion, nowMillis)
lastWeekVersion := weeklyVersion - 7*24*60*60*1000
lastWeekRewards := s.resolveWeeklyRewards(user, lastWeekVersion, nowMillis)
return &pb.GetBigHuntTopDataResponse{
WeeklyScoreResult: weeklyScoreResults,
WeeklyScoreReward: weeklyRewards,
IsReceivedWeeklyScoreReward: ws.IsReceivedWeeklyReward,
LastWeekWeeklyScoreReward: lastWeekRewards,
DiffUserData: buildBigHuntDiff(user, bigHuntDiffTables),
}, nil
}
func (s *BigHuntServiceServer) resolveWeeklyRewards(user store.UserState, weeklyVersion, nowMillis int64) []*pb.BigHuntReward {
var rewards []*pb.BigHuntReward
for _, boss := range s.catalog.BossByBossId {
rewardKey := masterdata.BigHuntWeeklyRewardKey{
ScheduleId: 1,
AttributeType: boss.AttributeType,
}
rewardGroupId := s.catalog.ResolveActiveWeeklyRewardGroupId(rewardKey, nowMillis)
if rewardGroupId == 0 {
continue
}
weekKey := store.BigHuntWeeklyScoreKey{
BigHuntWeeklyVersion: weeklyVersion,
AttributeType: boss.AttributeType,
}
maxScore := user.BigHuntWeeklyMaxScores[weekKey].MaxScore
for _, item := range s.catalog.CollectNewRewards(rewardGroupId, 0, maxScore) {
rewards = append(rewards, &pb.BigHuntReward{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
Count: item.Count,
})
}
}
if rewards == nil {
rewards = []*pb.BigHuntReward{}
}
return rewards
}
+149
View File
@@ -0,0 +1,149 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
func (s *QuestServiceServer) StartEventQuest(ctx context.Context, req *pb.StartEventQuestRequest) (*pb.StartEventQuestResponse, error) {
log.Printf("[QuestService] StartEventQuest: chapterId=%d questId=%d isBattleOnly=%v", req.EventQuestChapterId, req.QuestId, req.IsBattleOnly)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleEventQuestStart(user, req.EventQuestChapterId, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis)
})
drops := s.engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{
QuestSceneId: d.QuestSceneId,
BattleDropCategoryId: d.BattleDropCategoryId,
BattleDropEffectId: 1,
}
}
return &pb.StartEventQuestResponse{
BattleDropReward: pbDrops,
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserStatus",
"IUserQuest",
"IUserQuestMission",
"IUserEventQuestProgressStatus",
}),
}, nil
}
func (s *QuestServiceServer) FinishEventQuest(ctx context.Context, req *pb.FinishEventQuestRequest) (*pb.FinishEventQuestResponse, error) {
log.Printf("[QuestService] FinishEventQuest: chapterId=%d questId=%d isRetired=%v isAnnihilated=%v", req.EventQuestChapterId, req.QuestId, req.IsRetired, req.IsAnnihilated)
nowMillis := gametime.NowMillis()
userId := currentUserId(ctx, s.users, s.sessions)
var outcome questflow.FinishOutcome
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
outcome = s.engine.HandleEventQuestFinish(user, req.EventQuestChapterId, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis)
})
return &pb.FinishEventQuestResponse{
DropReward: toProtoRewards(outcome.DropRewards),
FirstClearReward: toProtoRewards(outcome.FirstClearRewards),
MissionClearReward: toProtoRewards(outcome.MissionClearRewards),
MissionClearCompleteReward: toProtoRewards(outcome.MissionClearCompleteRewards),
AutoOrbitResult: []*pb.QuestReward{},
IsBigWin: outcome.IsBigWin,
BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds,
UserStatusCampaignReward: []*pb.QuestReward{},
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserQuest",
"IUserQuestMission",
"IUserEventQuestProgressStatus",
"IUserStatus",
"IUserGem",
"IUserCharacter",
"IUserCostume",
"IUserCostumeActiveSkill",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserWeaponNote",
"IUserWeaponStory",
"IUserCompanion",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
"IUserParts",
"IUserPartsGroupNote",
}),
}, nil
}
func (s *QuestServiceServer) RestartEventQuest(ctx context.Context, req *pb.RestartEventQuestRequest) (*pb.RestartEventQuestResponse, error) {
log.Printf("[QuestService] RestartEventQuest: chapterId=%d questId=%d", req.EventQuestChapterId, req.QuestId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleEventQuestRestart(user, req.EventQuestChapterId, req.QuestId, gametime.NowMillis())
})
return &pb.RestartEventQuestResponse{
BattleDropReward: []*pb.BattleDropReward{},
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserQuest",
"IUserQuestMission",
"IUserEventQuestProgressStatus",
}),
}, nil
}
func (s *QuestServiceServer) UpdateEventQuestSceneProgress(ctx context.Context, req *pb.UpdateEventQuestSceneProgressRequest) (*pb.UpdateEventQuestSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateEventQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleEventQuestSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
})
return &pb.UpdateEventQuestSceneProgressResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserEventQuestProgressStatus",
"IUserCharacter",
"IUserCostume",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserCompanion",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
"IUserParts",
"IUserPartsGroupNote",
}),
}, nil
}
const defaultGuerrillaFreeOpenMinutes = int32(60)
func (s *QuestServiceServer) StartGuerrillaFreeOpen(ctx context.Context, req *emptypb.Empty) (*pb.StartGuerrillaFreeOpenResponse, error) {
log.Printf("[QuestService] StartGuerrillaFreeOpen")
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.GuerrillaFreeOpen.StartDatetime = nowMillis
user.GuerrillaFreeOpen.OpenMinutes = defaultGuerrillaFreeOpenMinutes
user.GuerrillaFreeOpen.DailyOpenedCount++
user.GuerrillaFreeOpen.LatestVersion = nowMillis
})
return &pb.StartGuerrillaFreeOpenResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{"IUserEventQuestGuerrillaFreeOpen"}),
}, nil
}
+138
View File
@@ -0,0 +1,138 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store"
)
func (s *QuestServiceServer) StartExtraQuest(ctx context.Context, req *pb.StartExtraQuestRequest) (*pb.StartExtraQuestResponse, error) {
log.Printf("[QuestService] StartExtraQuest: questId=%d deckNumber=%d", req.QuestId, req.UserDeckNumber)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleExtraQuestStart(user, req.QuestId, req.UserDeckNumber, nowMillis)
})
drops := s.engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{
QuestSceneId: d.QuestSceneId,
BattleDropCategoryId: d.BattleDropCategoryId,
BattleDropEffectId: 1,
}
}
return &pb.StartExtraQuestResponse{
BattleDropReward: pbDrops,
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserStatus",
"IUserQuest",
"IUserQuestMission",
"IUserExtraQuestProgressStatus",
}),
}, nil
}
func (s *QuestServiceServer) FinishExtraQuest(ctx context.Context, req *pb.FinishExtraQuestRequest) (*pb.FinishExtraQuestResponse, error) {
log.Printf("[QuestService] FinishExtraQuest: questId=%d isRetired=%v isAnnihilated=%v", req.QuestId, req.IsRetired, req.IsAnnihilated)
nowMillis := gametime.NowMillis()
userId := currentUserId(ctx, s.users, s.sessions)
var outcome questflow.FinishOutcome
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
outcome = s.engine.HandleExtraQuestFinish(user, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis)
})
return &pb.FinishExtraQuestResponse{
DropReward: toProtoRewards(outcome.DropRewards),
FirstClearReward: toProtoRewards(outcome.FirstClearRewards),
MissionClearReward: toProtoRewards(outcome.MissionClearRewards),
MissionClearCompleteReward: toProtoRewards(outcome.MissionClearCompleteRewards),
IsBigWin: outcome.IsBigWin,
BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds,
UserStatusCampaignReward: []*pb.QuestReward{},
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserQuest",
"IUserQuestMission",
"IUserExtraQuestProgressStatus",
"IUserStatus",
"IUserGem",
"IUserCharacter",
"IUserCostume",
"IUserCostumeActiveSkill",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserWeaponNote",
"IUserWeaponStory",
"IUserCompanion",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
"IUserParts",
"IUserPartsGroupNote",
}),
}, nil
}
func (s *QuestServiceServer) RestartExtraQuest(ctx context.Context, req *pb.RestartExtraQuestRequest) (*pb.RestartExtraQuestResponse, error) {
log.Printf("[QuestService] RestartExtraQuest: questId=%d", req.QuestId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleExtraQuestRestart(user, req.QuestId, gametime.NowMillis())
})
drops := s.engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{
QuestSceneId: d.QuestSceneId,
BattleDropCategoryId: d.BattleDropCategoryId,
BattleDropEffectId: 1,
}
}
return &pb.RestartExtraQuestResponse{
BattleDropReward: pbDrops,
DeckNumber: user.Quests[req.QuestId].UserDeckNumber,
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserQuest",
"IUserQuestMission",
"IUserExtraQuestProgressStatus",
}),
}, nil
}
func (s *QuestServiceServer) UpdateExtraQuestSceneProgress(ctx context.Context, req *pb.UpdateExtraQuestSceneProgressRequest) (*pb.UpdateExtraQuestSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateExtraQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleExtraQuestSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
})
return &pb.UpdateExtraQuestSceneProgressResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserExtraQuestProgressStatus",
"IUserCharacter",
"IUserCostume",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserCompanion",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
"IUserParts",
"IUserPartsGroupNote",
}),
}, nil
}
+366
View File
@@ -0,0 +1,366 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
type QuestServiceServer struct {
pb.UnimplementedQuestServiceServer
users store.UserRepository
sessions store.SessionRepository
engine *questflow.QuestHandler
}
func NewQuestServiceServer(users store.UserRepository, sessions store.SessionRepository, engine *questflow.QuestHandler) *QuestServiceServer {
if engine == nil {
panic("quest handler is required")
}
return &QuestServiceServer{users: users, sessions: sessions, engine: engine}
}
func buildSelectedQuestDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData {
tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames)
return userdata.BuildDiffFromTablesOrdered(tables, tableNames)
}
func (s *QuestServiceServer) UpdateMainFlowSceneProgress(ctx context.Context, req *pb.UpdateMainFlowSceneProgressRequest) (*pb.UpdateMainFlowSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateMainFlowSceneProgress: questSceneId=%d", req.QuestSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleMainFlowSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
})
return &pb.UpdateMainFlowSceneProgressResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserMainQuestFlowStatus",
"IUserMainQuestMainFlowStatus",
"IUserMainQuestProgressStatus",
"IUserMainQuestSeasonRoute",
"IUserPortalCageStatus",
"IUserSideStoryQuestSceneProgressStatus",
"IUserQuest",
"IUserCharacter",
"IUserCostume",
"IUserCostumeActiveSkill",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserWeaponNote",
"IUserWeaponStory",
"IUserCompanion",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
"IUserParts",
"IUserPartsGroupNote",
}),
}, nil
}
func (s *QuestServiceServer) UpdateReplayFlowSceneProgress(ctx context.Context, req *pb.UpdateReplayFlowSceneProgressRequest) (*pb.UpdateReplayFlowSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateReplayFlowSceneProgress: questSceneId=%d", req.QuestSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleReplayFlowSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
})
return &pb.UpdateReplayFlowSceneProgressResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserMainQuestFlowStatus",
"IUserMainQuestReplayFlowStatus",
}),
}, nil
}
func (s *QuestServiceServer) UpdateMainQuestSceneProgress(ctx context.Context, req *pb.UpdateMainQuestSceneProgressRequest) (*pb.UpdateMainQuestSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateMainQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleMainQuestSceneProgress(user, req.QuestSceneId)
})
return &pb.UpdateMainQuestSceneProgressResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserStatus",
"IUserCharacter",
"IUserQuest",
"IUserQuestMission",
"IUserMainQuestFlowStatus",
"IUserMainQuestMainFlowStatus",
"IUserMainQuestProgressStatus",
}),
}, nil
}
func (s *QuestServiceServer) StartMainQuest(ctx context.Context, req *pb.StartMainQuestRequest) (*pb.StartMainQuestResponse, error) {
log.Printf("[QuestService] StartMainQuest: %+v", req)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
if req.IsReplayFlow {
s.engine.HandleQuestStartReplay(user, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis)
} else {
s.engine.HandleQuestStart(user, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis)
}
})
drops := s.engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{
QuestSceneId: d.QuestSceneId,
BattleDropCategoryId: d.BattleDropCategoryId,
BattleDropEffectId: 1,
}
}
return &pb.StartMainQuestResponse{
BattleDropReward: pbDrops,
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserStatus",
"IUserQuest",
"IUserQuestMission",
"IUserMainQuestFlowStatus",
"IUserMainQuestMainFlowStatus",
"IUserMainQuestProgressStatus",
"IUserMainQuestSeasonRoute",
"IUserMainQuestReplayFlowStatus",
}),
}, nil
}
func toProtoRewards(grants []questflow.RewardGrant) []*pb.QuestReward {
if len(grants) == 0 {
return []*pb.QuestReward{}
}
out := make([]*pb.QuestReward, len(grants))
for i, g := range grants {
out[i] = &pb.QuestReward{
PossessionType: int32(g.PossessionType),
PossessionId: g.PossessionId,
Count: g.Count,
}
}
return out
}
func (s *QuestServiceServer) FinishMainQuest(ctx context.Context, req *pb.FinishMainQuestRequest) (*pb.FinishMainQuestResponse, error) {
log.Printf("[QuestService] FinishMainQuest: questId=%d isMainFlow=%v isRetired=%v isAnnihilated=%v storySkipType=%d",
req.QuestId, req.IsMainFlow, req.IsRetired, req.IsAnnihilated, req.StorySkipType)
nowMillis := gametime.NowMillis()
userId := currentUserId(ctx, s.users, s.sessions)
var outcome questflow.FinishOutcome
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
outcome = s.engine.HandleQuestFinish(user, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis)
})
return &pb.FinishMainQuestResponse{
DropReward: toProtoRewards(outcome.DropRewards),
FirstClearReward: toProtoRewards(outcome.FirstClearRewards),
MissionClearReward: toProtoRewards(outcome.MissionClearRewards),
MissionClearCompleteReward: toProtoRewards(outcome.MissionClearCompleteRewards),
AutoOrbitResult: []*pb.QuestReward{},
IsBigWin: outcome.IsBigWin,
BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds,
ReplayFlowFirstClearReward: toProtoRewards(outcome.ReplayFlowFirstClearRewards),
UserStatusCampaignReward: []*pb.QuestReward{},
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserQuest",
"IUserQuestMission",
"IUserMainQuestFlowStatus",
"IUserMainQuestMainFlowStatus",
"IUserMainQuestProgressStatus",
"IUserMainQuestSeasonRoute",
"IUserMainQuestReplayFlowStatus",
"IUserStatus",
"IUserGem",
"IUserCharacter",
"IUserCostume",
"IUserCostumeActiveSkill",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserWeaponNote",
"IUserWeaponStory",
"IUserCompanion",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
"IUserParts",
"IUserPartsGroupNote",
}),
}, nil
}
func (s *QuestServiceServer) RestartMainQuest(ctx context.Context, req *pb.RestartMainQuestRequest) (*pb.RestartMainQuestResponse, error) {
log.Printf("[QuestService] RestartMainQuest: questId=%d isMainFlow=%v", req.QuestId, req.IsMainFlow)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleQuestRestart(user, req.QuestId, gametime.NowMillis())
})
drops := s.engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{
QuestSceneId: d.QuestSceneId,
BattleDropCategoryId: d.BattleDropCategoryId,
BattleDropEffectId: 1,
}
}
return &pb.RestartMainQuestResponse{
BattleDropReward: pbDrops,
DeckNumber: user.Quests[req.QuestId].UserDeckNumber,
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserStatus",
"IUserQuest",
"IUserQuestMission",
"IUserMainQuestFlowStatus",
"IUserMainQuestMainFlowStatus",
"IUserMainQuestProgressStatus",
"IUserMainQuestSeasonRoute",
}),
}, nil
}
func (s *QuestServiceServer) FinishAutoOrbit(ctx context.Context, req *emptypb.Empty) (*pb.FinishAutoOrbitResponse, error) {
log.Printf("[QuestService] FinishAutoOrbit")
return &pb.FinishAutoOrbitResponse{
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *QuestServiceServer) SkipQuest(ctx context.Context, req *pb.SkipQuestRequest) (*pb.SkipQuestResponse, error) {
log.Printf("[QuestService] SkipQuest: questId=%d skipCount=%d useEffectItems=%d", req.QuestId, req.SkipCount, len(req.UseEffectItem))
nowMillis := gametime.NowMillis()
userId := currentUserId(ctx, s.users, s.sessions)
var outcome questflow.FinishOutcome
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, item := range req.UseEffectItem {
log.Printf("[QuestService] SkipQuest UseEffectItem: consumableItemId=%d count=%d", item.ConsumableItemId, item.Count)
user.ConsumableItems[item.ConsumableItemId] -= item.Count
if user.ConsumableItems[item.ConsumableItemId] < 0 {
user.ConsumableItems[item.ConsumableItemId] = 0
}
}
outcome = s.engine.HandleQuestSkip(user, req.QuestId, req.SkipCount, nowMillis)
})
return &pb.SkipQuestResponse{
DropReward: toProtoRewards(outcome.DropRewards),
UserStatusCampaignReward: []*pb.QuestReward{},
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserQuest",
"IUserStatus",
"IUserConsumableItem",
"IUserMaterial",
"IUserParts",
"IUserPartsGroupNote",
"IUserCharacter",
"IUserCostume",
}),
}, nil
}
func (s *QuestServiceServer) SetRoute(ctx context.Context, req *pb.SetRouteRequest) (*pb.SetRouteResponse, error) {
log.Printf("[QuestService] SetRoute: mainQuestRouteId=%d", req.MainQuestRouteId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.MainQuest.CurrentMainQuestRouteId = req.MainQuestRouteId
if seasonId, ok := s.engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok {
user.MainQuest.MainQuestSeasonId = seasonId
}
now := gametime.NowMillis()
user.PortalCageStatus.IsCurrentProgress = false
user.PortalCageStatus.LatestVersion = now
})
return &pb.SetRouteResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserMainQuestSeasonRoute",
"IUserMainQuestMainFlowStatus",
"IUserPortalCageStatus",
}),
}, nil
}
func (s *QuestServiceServer) SetQuestSceneChoice(ctx context.Context, req *pb.SetQuestSceneChoiceRequest) (*pb.SetQuestSceneChoiceResponse, error) {
log.Printf("[QuestService] SetQuestSceneChoice: questSceneId=%d choiceNumber=%d",
req.QuestSceneId, req.ChoiceNumber)
return &pb.SetQuestSceneChoiceResponse{
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *QuestServiceServer) ResetLimitContentQuestProgress(ctx context.Context, req *pb.ResetLimitContentQuestProgressRequest) (*pb.ResetLimitContentQuestProgressResponse, error) {
log.Printf("[QuestService] ResetLimitContentQuestProgress: eventQuestChapterId=%d questId=%d",
req.EventQuestChapterId, req.QuestId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
if _, exists := user.SideStoryQuests[req.QuestId]; exists {
user.SideStoryQuests[req.QuestId] = store.SideStoryQuestProgress{
HeadSideStoryQuestSceneId: 0,
SideStoryQuestStateType: model.SideStoryQuestStateUnknown,
LatestVersion: nowMillis,
}
}
delete(user.QuestLimitContentStatus, req.QuestId)
if user.SideStoryActiveProgress.CurrentSideStoryQuestId == req.QuestId {
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
LatestVersion: nowMillis,
}
}
})
return &pb.ResetLimitContentQuestProgressResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserSideStoryQuest",
"IUserSideStoryQuestSceneProgressStatus",
"IUserQuestLimitContentStatus",
}),
}, nil
}
func (s *QuestServiceServer) SetAutoSaleSetting(ctx context.Context, req *pb.SetAutoSaleSettingRequest) (*pb.SetAutoSaleSettingResponse, error) {
log.Printf("[QuestService] SetAutoSaleSetting: items=%d", len(req.AutoSaleSettingItem))
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.AutoSaleSettings = make(map[int32]store.AutoSaleSettingState, len(req.AutoSaleSettingItem))
for itemType, itemValue := range req.AutoSaleSettingItem {
user.AutoSaleSettings[itemType] = store.AutoSaleSettingState{
PossessionAutoSaleItemType: itemType,
PossessionAutoSaleItemValue: itemValue,
}
}
})
return &pb.SetAutoSaleSettingResponse{
DiffUserData: buildSelectedQuestDiff(user, []string{
"IUserAutoSaleSettingDetail",
}),
}, nil
}
@@ -0,0 +1,93 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type SideStoryQuestServiceServer struct {
pb.UnimplementedSideStoryQuestServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.SideStoryCatalog
}
func NewSideStoryQuestServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.SideStoryCatalog) *SideStoryQuestServiceServer {
return &SideStoryQuestServiceServer{users: users, sessions: sessions, catalog: catalog}
}
func buildSideStoryDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData {
tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames)
return userdata.BuildDiffFromTablesOrdered(tables, tableNames)
}
func (s *SideStoryQuestServiceServer) MoveSideStoryQuestProgress(ctx context.Context, req *pb.MoveSideStoryQuestRequest) (*pb.MoveSideStoryQuestResponse, error) {
log.Printf("[SideStoryQuestService] MoveSideStoryQuestProgress: sideStoryQuestId=%d", req.SideStoryQuestId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
firstSceneId := s.catalog.FirstSceneByQuestId[req.SideStoryQuestId]
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
existing, exists := user.SideStoryQuests[req.SideStoryQuestId]
var sceneId int32
if exists && existing.HeadSideStoryQuestSceneId > 0 {
sceneId = existing.HeadSideStoryQuestSceneId
} else {
sceneId = firstSceneId
}
user.SideStoryActiveProgress.CurrentSideStoryQuestId = req.SideStoryQuestId
user.SideStoryActiveProgress.CurrentSideStoryQuestSceneId = sceneId
user.SideStoryActiveProgress.LatestVersion = nowMillis
if !exists {
user.SideStoryQuests[req.SideStoryQuestId] = store.SideStoryQuestProgress{
HeadSideStoryQuestSceneId: firstSceneId,
SideStoryQuestStateType: model.SideStoryQuestStateActive,
LatestVersion: nowMillis,
}
}
})
return &pb.MoveSideStoryQuestResponse{
DiffUserData: buildSideStoryDiff(user, []string{
"IUserSideStoryQuest",
"IUserSideStoryQuestSceneProgressStatus",
}),
}, nil
}
func (s *SideStoryQuestServiceServer) UpdateSideStoryQuestSceneProgress(ctx context.Context, req *pb.UpdateSideStoryQuestSceneProgressRequest) (*pb.UpdateSideStoryQuestSceneProgressResponse, error) {
log.Printf("[SideStoryQuestService] UpdateSideStoryQuestSceneProgress: sideStoryQuestId=%d sceneId=%d",
req.SideStoryQuestId, req.SideStoryQuestSceneId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.SideStoryActiveProgress.CurrentSideStoryQuestSceneId = req.SideStoryQuestSceneId
user.SideStoryActiveProgress.LatestVersion = nowMillis
progress := user.SideStoryQuests[req.SideStoryQuestId]
if req.SideStoryQuestSceneId > progress.HeadSideStoryQuestSceneId {
progress.HeadSideStoryQuestSceneId = req.SideStoryQuestSceneId
}
progress.LatestVersion = nowMillis
user.SideStoryQuests[req.SideStoryQuestId] = progress
})
return &pb.UpdateSideStoryQuestSceneProgressResponse{
DiffUserData: buildSideStoryDiff(user, []string{
"IUserSideStoryQuest",
"IUserSideStoryQuestSceneProgressStatus",
}),
}, nil
}
+144
View File
@@ -0,0 +1,144 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
type RewardServiceServer struct {
pb.UnimplementedRewardServiceServer
users store.UserRepository
sessions store.SessionRepository
bhCatalog *masterdata.BigHuntCatalog
granter *store.PossessionGranter
}
func NewRewardServiceServer(
users store.UserRepository,
sessions store.SessionRepository,
bhCatalog *masterdata.BigHuntCatalog,
granter *store.PossessionGranter,
) *RewardServiceServer {
return &RewardServiceServer{users: users, sessions: sessions, bhCatalog: bhCatalog, granter: granter}
}
func (s *RewardServiceServer) ReceiveBigHuntReward(ctx context.Context, _ *emptypb.Empty) (*pb.ReceiveBigHuntRewardResponse, error) {
log.Printf("[RewardService] ReceiveBigHuntReward")
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
weeklyVersion := gametime.WeeklyVersion(nowMillis)
var weeklyScoreResults []*pb.WeeklyScoreResult
var weeklyRewards []*pb.BigHuntReward
isReceived := false
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
ws := user.BigHuntWeeklyStatuses[weeklyVersion]
isReceived = ws.IsReceivedWeeklyReward
for _, boss := range s.bhCatalog.BossByBossId {
key := store.BigHuntWeeklyScoreKey{
BigHuntWeeklyVersion: weeklyVersion,
AttributeType: boss.AttributeType,
}
wms := user.BigHuntWeeklyMaxScores[key]
gradeIcon := s.bhCatalog.ResolveGradeIconId(boss.BigHuntBossId, wms.MaxScore)
weeklyScoreResults = append(weeklyScoreResults, &pb.WeeklyScoreResult{
AttributeType: boss.AttributeType,
BeforeMaxScore: wms.MaxScore,
CurrentMaxScore: wms.MaxScore,
BeforeAssetGradeIconId: gradeIcon,
CurrentAssetGradeIconId: gradeIcon,
AfterMaxScore: wms.MaxScore,
AfterAssetGradeIconId: gradeIcon,
})
}
if !isReceived {
for _, boss := range s.bhCatalog.BossByBossId {
rewardKey := masterdata.BigHuntWeeklyRewardKey{
ScheduleId: 1,
AttributeType: boss.AttributeType,
}
rewardGroupId := s.bhCatalog.ResolveActiveWeeklyRewardGroupId(rewardKey, nowMillis)
if rewardGroupId == 0 {
continue
}
weekKey := store.BigHuntWeeklyScoreKey{
BigHuntWeeklyVersion: weeklyVersion,
AttributeType: boss.AttributeType,
}
maxScore := user.BigHuntWeeklyMaxScores[weekKey].MaxScore
items := s.bhCatalog.CollectNewRewards(rewardGroupId, 0, maxScore)
for _, item := range items {
s.granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis)
weeklyRewards = append(weeklyRewards, &pb.BigHuntReward{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
Count: item.Count,
})
}
}
ws.IsReceivedWeeklyReward = true
ws.LatestVersion = nowMillis
user.BigHuntWeeklyStatuses[weeklyVersion] = ws
isReceived = true
}
})
if weeklyRewards == nil {
weeklyRewards = []*pb.BigHuntReward{}
}
if weeklyScoreResults == nil {
weeklyScoreResults = []*pb.WeeklyScoreResult{}
}
tables := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
"IUserBigHuntWeeklyStatus",
"IUserBigHuntWeeklyMaxScore",
"IUserConsumableItem",
"IUserMaterial",
})
return &pb.ReceiveBigHuntRewardResponse{
WeeklyScoreResult: weeklyScoreResults,
WeeklyScoreReward: weeklyRewards,
IsReceivedWeeklyScoreReward: isReceived,
LastWeekWeeklyScoreReward: []*pb.BigHuntReward{},
DiffUserData: userdata.BuildDiffFromTables(tables),
}, nil
}
func (s *RewardServiceServer) ReceivePvpReward(ctx context.Context, _ *emptypb.Empty) (*pb.ReceivePvpRewardResponse, error) {
log.Printf("[RewardService] ReceivePvpReward (stub)")
return &pb.ReceivePvpRewardResponse{
DiffUserData: map[string]*pb.DiffData{},
}, nil
}
func (s *RewardServiceServer) ReceiveLabyrinthSeasonReward(ctx context.Context, _ *emptypb.Empty) (*pb.ReceiveLabyrinthSeasonRewardResponse, error) {
log.Printf("[RewardService] ReceiveLabyrinthSeasonReward (stub)")
return &pb.ReceiveLabyrinthSeasonRewardResponse{
DiffUserData: map[string]*pb.DiffData{},
}, nil
}
func (s *RewardServiceServer) ReceiveMissionPassRemainingReward(ctx context.Context, _ *emptypb.Empty) (*pb.ReceiveMissionPassRemainingRewardResponse, error) {
log.Printf("[RewardService] ReceiveMissionPassRemainingReward (stub)")
return &pb.ReceiveMissionPassRemainingRewardResponse{
DiffUserData: map[string]*pb.DiffData{},
}, nil
}
+248
View File
@@ -0,0 +1,248 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
"google.golang.org/protobuf/types/known/emptypb"
)
var shopDiffTables = []string{
"IUserShopItem",
"IUserShopReplaceable",
"IUserShopReplaceableLineup",
"IUserGem",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
"IUserPremiumItem",
"IUserStatus",
"IUserCostume",
"IUserCostumeActiveSkill",
"IUserCharacter",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserWeaponNote",
"IUserWeaponStory",
}
type ShopServiceServer struct {
pb.UnimplementedShopServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.ShopCatalog
granter *store.PossessionGranter
}
func NewShopServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.ShopCatalog, granter *store.PossessionGranter) *ShopServiceServer {
return &ShopServiceServer{users: users, sessions: sessions, catalog: catalog, granter: granter}
}
func (s *ShopServiceServer) Buy(ctx context.Context, req *pb.BuyRequest) (*pb.BuyResponse, error) {
log.Printf("[ShopService] Buy: shopId=%d items=%v", req.ShopId, req.ShopItems)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
for shopItemId, qty := range req.ShopItems {
item, ok := s.catalog.Items[shopItemId]
if !ok {
log.Printf("[ShopService] Buy: unknown shopItemId=%d, skipping", shopItemId)
continue
}
totalPrice := item.Price * qty
if err := store.DeductPrice(user, item.PriceType, item.PriceId, totalPrice); err != nil {
log.Printf("[ShopService] Buy: deduct failed shopItemId=%d: %v", shopItemId, err)
continue
}
for _, content := range s.catalog.Contents[shopItemId] {
s.granter.GrantFull(user,
model.PossessionType(content.PossessionType),
content.PossessionId,
content.Count*qty,
nowMillis,
)
}
s.applyContentEffects(user, shopItemId, qty, nowMillis)
si := user.ShopItems[shopItemId]
si.ShopItemId = shopItemId
si.BoughtCount += qty
si.LatestBoughtCountChangedDatetime = nowMillis
si.LatestVersion = nowMillis
user.ShopItems[shopItemId] = si
}
})
if err != nil {
return nil, fmt.Errorf("shop buy: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables))
return &pb.BuyResponse{
OverflowPossession: []*pb.Possession{},
DiffUserData: diff,
}, nil
}
func (s *ShopServiceServer) RefreshUserData(ctx context.Context, req *pb.RefreshRequest) (*pb.RefreshResponse, error) {
log.Printf("[ShopService] RefreshUserData: isGemUsed=%v", req.IsGemUsed)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
if len(user.ShopReplaceableLineup) == 0 && len(s.catalog.ItemShopPool) > 0 {
for i, itemId := range s.catalog.ItemShopPool {
slot := int32(i + 1)
user.ShopReplaceableLineup[slot] = store.UserShopReplaceableLineupState{
SlotNumber: slot,
ShopItemId: itemId,
LatestVersion: nowMillis,
}
}
}
if req.IsGemUsed {
user.ShopReplaceable.LineupUpdateCount++
user.ShopReplaceable.LatestLineupUpdateDatetime = nowMillis
for _, itemId := range s.catalog.ItemShopPool {
if si, ok := user.ShopItems[itemId]; ok {
si.BoughtCount = 0
si.LatestVersion = nowMillis
user.ShopItems[itemId] = si
}
}
}
})
if err != nil {
return nil, fmt.Errorf("shop refresh: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables))
return &pb.RefreshResponse{
DiffUserData: diff,
}, nil
}
func (s *ShopServiceServer) GetCesaLimit(_ context.Context, _ *emptypb.Empty) (*pb.GetCesaLimitResponse, error) {
log.Printf("[ShopService] GetCesaLimit")
return &pb.GetCesaLimitResponse{
CesaLimit: []*pb.CesaLimit{},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *ShopServiceServer) CreatePurchaseTransaction(ctx context.Context, req *pb.CreatePurchaseTransactionRequest) (*pb.CreatePurchaseTransactionResponse, error) {
log.Printf("[ShopService] CreatePurchaseTransaction: shopId=%d shopItemId=%d productId=%s",
req.ShopId, req.ShopItemId, req.ProductId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
item, ok := s.catalog.Items[req.ShopItemId]
if !ok {
log.Printf("[ShopService] CreatePurchaseTransaction: unknown shopItemId=%d", req.ShopItemId)
return
}
if err := store.DeductPrice(user, item.PriceType, item.PriceId, item.Price); err != nil {
log.Printf("[ShopService] CreatePurchaseTransaction: deduct failed: %v", err)
}
for _, content := range s.catalog.Contents[req.ShopItemId] {
s.granter.GrantFull(user,
model.PossessionType(content.PossessionType),
content.PossessionId,
content.Count,
nowMillis,
)
}
s.applyContentEffects(user, req.ShopItemId, 1, nowMillis)
si := user.ShopItems[req.ShopItemId]
si.ShopItemId = req.ShopItemId
si.BoughtCount++
if item.ShopItemLimitedStockId > 0 {
if maxCount, ok := s.catalog.LimitedStock[item.ShopItemLimitedStockId]; ok && si.BoughtCount >= maxCount {
si.BoughtCount = 0
}
}
si.LatestBoughtCountChangedDatetime = nowMillis
si.LatestVersion = nowMillis
user.ShopItems[req.ShopItemId] = si
})
if err != nil {
return nil, fmt.Errorf("create purchase transaction: %w", err)
}
txId := fmt.Sprintf("tx_%d_%d_%d", userId, req.ShopItemId, nowMillis)
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables))
return &pb.CreatePurchaseTransactionResponse{
PurchaseTransactionId: txId,
DiffUserData: diff,
}, nil
}
func (s *ShopServiceServer) PurchaseGooglePlayStoreProduct(ctx context.Context, req *pb.PurchaseGooglePlayStoreProductRequest) (*pb.PurchaseGooglePlayStoreProductResponse, error) {
log.Printf("[ShopService] PurchaseGooglePlayStoreProduct: txId=%s", req.PurchaseTransactionId)
userId := currentUserId(ctx, s.users, s.sessions)
snapshot, err := s.users.SnapshotUser(userId)
if err != nil {
return nil, fmt.Errorf("purchase google play: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables))
return &pb.PurchaseGooglePlayStoreProductResponse{
OverflowPossession: []*pb.Possession{},
DiffUserData: diff,
}, nil
}
func (s *ShopServiceServer) applyContentEffects(user *store.UserState, shopItemId, qty int32, nowMillis int64) {
for _, effect := range s.catalog.Effects[shopItemId] {
switch effect.EffectTargetType {
case model.EffectTargetStaminaRecovery:
maxMillis := s.catalog.MaxStaminaMillis[user.Status.Level]
millis := s.resolveEffectMillis(effect.EffectValueType, effect.EffectValue, user.Status.Level)
store.RecoverStamina(user, millis*qty, maxMillis, nowMillis)
default:
log.Printf("[ShopService] unhandled effect: shopItemId=%d targetType=%d", shopItemId, effect.EffectTargetType)
}
}
}
func (s *ShopServiceServer) resolveEffectMillis(effectValueType, effectValue, userLevel int32) int32 {
switch effectValueType {
case model.EffectValueFixed:
return effectValue
case model.EffectValuePermil:
maxMillis := s.catalog.MaxStaminaMillis[userLevel]
return effectValue * maxMillis / 1000
default:
return 0
}
}
+59
View File
@@ -0,0 +1,59 @@
package service
import (
"context"
"lunar-tear/server/internal/store"
"google.golang.org/grpc/metadata"
)
var startedGameStartTables = []string{
"IUserProfile",
"IUserCharacter",
"IUserCostume",
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserCompanion",
"IUserDeckCharacter",
"IUserDeck",
"IUserGem",
"IUserMission",
"IUserMainQuestFlowStatus",
"IUserMainQuestMainFlowStatus",
"IUserMainQuestProgressStatus",
"IUserMainQuestSeasonRoute",
"IUserQuest",
"IUserQuestMission",
"IUserTutorialProgress",
"IUserWeaponNote",
"IUserWeaponStory",
"IUserCostumeActiveSkill",
"IUserDeckTypeNote",
"IUserDeckSubWeaponGroup",
"IUserDeckPartsGroup",
"IUserConsumableItem",
"IUserMaterial",
"IUserImportantItem",
}
var gimmickDiffTables = []string{
"IUserGimmick",
"IUserGimmickOrnamentProgress",
"IUserGimmickSequence",
"IUserGimmickUnlock",
}
func currentUserId(ctx context.Context, users store.UserRepository, sessions store.SessionRepository) int64 {
if md, ok := metadata.FromIncomingContext(ctx); ok {
if vals := md.Get("x-session-key"); len(vals) > 0 {
if userId, err := sessions.ResolveUserId(vals[0]); err == nil {
return userId
}
}
}
defaultId, _ := users.DefaultUserId()
return defaultId
}
+93
View File
@@ -0,0 +1,93 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
type TutorialServiceServer struct {
pb.UnimplementedTutorialServiceServer
users store.UserRepository
sessions store.SessionRepository
engine *questflow.QuestHandler
}
func NewTutorialServiceServer(users store.UserRepository, sessions store.SessionRepository, engine *questflow.QuestHandler) *TutorialServiceServer {
return &TutorialServiceServer{users: users, sessions: sessions, engine: engine}
}
func (s *TutorialServiceServer) SetTutorialProgress(ctx context.Context, req *pb.SetTutorialProgressRequest) (*pb.SetTutorialProgressResponse, error) {
log.Printf("[TutorialService] SetTutorialProgress: type=%d phase=%d choice=%d", req.TutorialType, req.ProgressPhase, req.ChoiceId)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
var grants []questflow.RewardGrant
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.Tutorials[req.TutorialType] = store.TutorialProgressState{
TutorialType: req.TutorialType,
ProgressPhase: req.ProgressPhase,
ChoiceId: req.ChoiceId,
}
if req.TutorialType == int32(model.TutorialTypeMenuFirst) ||
req.TutorialType == int32(model.TutorialTypeMenuSecond) {
store.EnsureDefaultDeck(user, nowMillis)
}
grants = s.engine.ApplyTutorialReward(user, model.TutorialType(req.TutorialType), req.ChoiceId, nowMillis)
})
tables := []string{"IUserTutorialProgress"}
if req.TutorialType == int32(model.TutorialTypeMenuFirst) ||
req.TutorialType == int32(model.TutorialTypeMenuSecond) {
tables = append(tables,
"IUserCharacter", "IUserCostume", "IUserWeapon",
"IUserWeaponSkill", "IUserWeaponAbility",
"IUserCompanion", "IUserDeckCharacter", "IUserDeck",
)
}
if len(grants) > 0 {
tables = append(tables, "IUserCompanion")
}
result := userdata.SelectTables(userdata.FullClientTableMap(user), tables)
for _, t := range tables {
log.Printf("[TutorialService] DiffTable %s -> %s", t, result[t])
}
rewards := make([]*pb.TutorialChoiceReward, len(grants))
for i, g := range grants {
rewards[i] = &pb.TutorialChoiceReward{
PossessionType: int32(g.PossessionType),
PossessionId: g.PossessionId,
Count: g.Count,
}
}
return &pb.SetTutorialProgressResponse{
TutorialChoiceReward: rewards,
DiffUserData: userdata.BuildDiffFromTables(result),
}, nil
}
func (s *TutorialServiceServer) SetTutorialProgressAndReplaceDeck(ctx context.Context, req *pb.SetTutorialProgressAndReplaceDeckRequest) (*pb.SetTutorialProgressAndReplaceDeckResponse, error) {
log.Printf("[TutorialService] SetTutorialProgressAndReplaceDeck: type=%d phase=%d deckType=%d deckNumber=%d", req.TutorialType, req.ProgressPhase, req.DeckType, req.UserDeckNumber)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.Tutorials[req.TutorialType] = store.TutorialProgressState{
TutorialType: req.TutorialType,
ProgressPhase: req.ProgressPhase,
}
if req.Deck != nil {
store.ApplyDeckReplacement(user, model.DeckType(req.DeckType), req.UserDeckNumber, deckSlotsFromProto(req.Deck), gametime.NowMillis())
}
})
return &pb.SetTutorialProgressAndReplaceDeckResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{
"IUserTutorialProgress",
"IUserDeck",
"IUserDeckCharacter",
"IUserDeckSubWeaponGroup",
})),
}, nil
}
+268
View File
@@ -0,0 +1,268 @@
package service
import (
"context"
"fmt"
"log"
"sort"
"time"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
type UserServiceServer struct {
pb.UnimplementedUserServiceServer
users store.UserRepository
sessions store.SessionRepository
}
func NewUserServiceServer(users store.UserRepository, sessions store.SessionRepository) *UserServiceServer {
return &UserServiceServer{users: users, sessions: sessions}
}
func setCommonResponseTrailers(ctx context.Context, diff map[string]*pb.DiffData, includeUpdateNames bool) {
keys := make([]string, 0, len(diff))
for key := range diff {
keys = append(keys, key)
}
sort.Strings(keys)
var pairs []string
if includeUpdateNames && len(keys) > 0 {
pairs = append(pairs, "x-apb-update-user-data-names", keys[0])
for _, key := range keys[1:] {
pairs[len(pairs)-1] += "," + key
}
}
if err := grpc.SetTrailer(ctx, metadata.Pairs(pairs...)); err != nil {
log.Printf("[UserService] failed to set trailers: %v", err)
}
}
func (s *UserServiceServer) RegisterUser(ctx context.Context, req *pb.RegisterUserRequest) (*pb.RegisterUserResponse, error) {
user, err := s.users.EnsureUser(req.Uuid)
if err != nil {
return nil, fmt.Errorf("ensure user: %w", err)
}
log.Printf("[UserService] RegisterUser: uuid=%s terminalId=%s -> userId=%d", req.Uuid, req.TerminalId, user.UserId)
return &pb.RegisterUserResponse{
UserId: user.UserId,
Signature: fmt.Sprintf("sig_%d_%d", user.UserId, gametime.Now().Unix()),
DiffUserData: userdata.BuildDiffFromTables(userdata.FirstEntranceClientTableMap(user)),
}, nil
}
func (s *UserServiceServer) Auth(ctx context.Context, req *pb.AuthUserRequest) (*pb.AuthUserResponse, error) {
log.Printf("[UserService] Auth: uuid=%s", req.Uuid)
user, session, err := s.sessions.CreateSession(req.Uuid, 24*time.Hour)
if err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
return &pb.AuthUserResponse{
SessionKey: session.SessionKey,
ExpireDatetime: timestamppb.New(session.ExpireAt),
Signature: req.Signature,
UserId: user.UserId,
DiffUserData: userdata.BuildDiffFromTables(userdata.FirstEntranceClientTableMap(user)),
}, nil
}
func (s *UserServiceServer) GameStart(ctx context.Context, _ *emptypb.Empty) (*pb.GameStartResponse, error) {
log.Printf("[UserService] GameStart")
if md, ok := metadata.FromIncomingContext(ctx); ok {
if vals := md.Get("x-session-key"); len(vals) > 0 {
log.Printf("[UserService] GameStart session: %s", vals[0])
}
}
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.GameStartDatetime = gametime.NowMillis()
})
fullTables := userdata.FullClientTableMap(user)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(fullTables, startedGameStartTables))
setCommonResponseTrailers(ctx, diff, true)
return &pb.GameStartResponse{
// Apply only the starter outgame rows we need after title completion.
// Keep IUser and other risky core-account rows out of GameStart diff.
DiffUserData: diff,
}, nil
}
func (s *UserServiceServer) TransferUser(ctx context.Context, req *pb.TransferUserRequest) (*pb.TransferUserResponse, error) {
log.Printf("[UserService] TransferUser")
user, err := s.users.EnsureUser(req.Uuid)
if err != nil {
return nil, fmt.Errorf("ensure user: %w", err)
}
return &pb.TransferUserResponse{
UserId: user.UserId,
Signature: "transferred-sig",
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *UserServiceServer) SetUserName(ctx context.Context, req *pb.SetUserNameRequest) (*pb.SetUserNameResponse, error) {
log.Printf("[UserService] SetUserName: %s", req.Name)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
nowMillis := gametime.NowMillis()
user.Profile.Name = req.Name
user.Profile.NameUpdateDatetime = nowMillis
})
return &pb.SetUserNameResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})),
}, nil
}
func (s *UserServiceServer) SetUserMessage(ctx context.Context, req *pb.SetUserMessageRequest) (*pb.SetUserMessageResponse, error) {
log.Printf("[UserService] SetUserMessage: %s", req.Message)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
nowMillis := gametime.NowMillis()
user.Profile.Message = req.Message
user.Profile.MessageUpdateDatetime = nowMillis
})
return &pb.SetUserMessageResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})),
}, nil
}
func (s *UserServiceServer) SetUserFavoriteCostumeId(ctx context.Context, req *pb.SetUserFavoriteCostumeIdRequest) (*pb.SetUserFavoriteCostumeIdResponse, error) {
log.Printf("[UserService] SetUserFavoriteCostumeId: %d", req.FavoriteCostumeId)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
nowMillis := gametime.NowMillis()
user.Profile.FavoriteCostumeId = req.FavoriteCostumeId
user.Profile.FavoriteCostumeIdUpdateDatetime = nowMillis
})
return &pb.SetUserFavoriteCostumeIdResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})),
}, nil
}
func (s *UserServiceServer) GetUserProfile(ctx context.Context, req *pb.GetUserProfileRequest) (*pb.GetUserProfileResponse, error) {
log.Printf("[UserService] GetUserProfile: playerId=%d", req.PlayerId)
userId := req.PlayerId
if userId == 0 {
userId = currentUserId(ctx, s.users, s.sessions)
}
user, err := s.users.SnapshotUser(userId)
if err != nil {
return &pb.GetUserProfileResponse{DiffUserData: userdata.EmptyDiff()}, nil
}
deckCharacters := []*pb.ProfileDeckCharacter{}
if deck, ok := user.Decks[store.DeckKey{DeckType: model.DeckTypeQuest, UserDeckNumber: 1}]; ok && deck.UserDeckCharacterUuid01 != "" {
if deckCharacter, ok := user.DeckCharacters[deck.UserDeckCharacterUuid01]; ok {
costumeId := int32(0)
if costume, ok := user.Costumes[deckCharacter.UserCostumeUuid]; ok {
costumeId = costume.CostumeId
}
mainWeaponId := int32(0)
mainWeaponLevel := int32(0)
if weapon, ok := user.Weapons[deckCharacter.MainUserWeaponUuid]; ok {
mainWeaponId = weapon.WeaponId
mainWeaponLevel = weapon.Level
}
deckCharacters = append(deckCharacters, &pb.ProfileDeckCharacter{
CostumeId: costumeId,
MainWeaponId: mainWeaponId,
MainWeaponLevel: mainWeaponLevel,
})
}
}
return &pb.GetUserProfileResponse{
Level: user.Status.Level,
Name: user.Profile.Name,
FavoriteCostumeId: user.Profile.FavoriteCostumeId,
Message: user.Profile.Message,
IsFriend: false,
LatestUsedDeck: &pb.ProfileDeck{
Power: 100,
DeckCharacter: deckCharacters,
},
PvpInfo: &pb.ProfilePvpInfo{},
GamePlayHistory: &pb.GamePlayHistory{
HistoryItem: []*pb.PlayHistoryItem{},
HistoryCategoryGraphItem: []*pb.PlayHistoryCategoryGraphItem{},
},
DiffUserData: userdata.EmptyDiff(),
}, nil
}
func (s *UserServiceServer) SetBirthYearMonth(ctx context.Context, req *pb.SetBirthYearMonthRequest) (*pb.SetBirthYearMonthResponse, error) {
log.Printf("[UserService] SetBirthYearMonth: %d/%d", req.BirthYear, req.BirthMonth)
userId := currentUserId(ctx, s.users, s.sessions)
_, _ = s.users.UpdateUser(userId, func(user *store.UserState) {
user.BirthYear = req.BirthYear
user.BirthMonth = req.BirthMonth
})
return &pb.SetBirthYearMonthResponse{DiffUserData: userdata.EmptyDiff()}, nil
}
func (s *UserServiceServer) GetBirthYearMonth(ctx context.Context, _ *emptypb.Empty) (*pb.GetBirthYearMonthResponse, error) {
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return &pb.GetBirthYearMonthResponse{BirthYear: 2000, BirthMonth: 1, DiffUserData: userdata.EmptyDiff()}, nil
}
return &pb.GetBirthYearMonthResponse{BirthYear: user.BirthYear, BirthMonth: user.BirthMonth, DiffUserData: userdata.EmptyDiff()}, nil
}
func (s *UserServiceServer) GetChargeMoney(ctx context.Context, _ *emptypb.Empty) (*pb.GetChargeMoneyResponse, error) {
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return &pb.GetChargeMoneyResponse{ChargeMoneyThisMonth: 0, DiffUserData: userdata.EmptyDiff()}, nil
}
return &pb.GetChargeMoneyResponse{ChargeMoneyThisMonth: user.ChargeMoneyThisMonth, DiffUserData: userdata.EmptyDiff()}, nil
}
func (s *UserServiceServer) SetUserSetting(ctx context.Context, req *pb.SetUserSettingRequest) (*pb.SetUserSettingResponse, error) {
log.Printf("[UserService] SetUserSetting: isNotifyPurchaseAlert=%v", req.IsNotifyPurchaseAlert)
userId := currentUserId(ctx, s.users, s.sessions)
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
user.Setting.IsNotifyPurchaseAlert = req.IsNotifyPurchaseAlert
})
return &pb.SetUserSettingResponse{
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserSetting"})),
}, nil
}
func (s *UserServiceServer) GetAndroidArgs(ctx context.Context, req *pb.GetAndroidArgsRequest) (*pb.GetAndroidArgsResponse, error) {
return &pb.GetAndroidArgsResponse{Nonce: "Mama", ApiKey: "1234567890", DiffUserData: userdata.EmptyDiff()}, nil
}
func (s *UserServiceServer) GetBackupToken(ctx context.Context, req *pb.GetBackupTokenRequest) (*pb.GetBackupTokenResponse, error) {
userId := currentUserId(ctx, s.users, s.sessions)
user, err := s.users.SnapshotUser(userId)
if err != nil {
return &pb.GetBackupTokenResponse{BackupToken: "mock-backup-token", DiffUserData: userdata.EmptyDiff()}, nil
}
return &pb.GetBackupTokenResponse{BackupToken: user.BackupToken, DiffUserData: userdata.EmptyDiff()}, nil
}
func (s *UserServiceServer) CheckTransferSetting(ctx context.Context, _ *emptypb.Empty) (*pb.CheckTransferSettingResponse, error) {
return &pb.CheckTransferSettingResponse{DiffUserData: userdata.EmptyDiff()}, nil
}
func (s *UserServiceServer) GetUserGamePlayNote(ctx context.Context, req *pb.GetUserGamePlayNoteRequest) (*pb.GetUserGamePlayNoteResponse, error) {
return &pb.GetUserGamePlayNoteResponse{DiffUserData: userdata.EmptyDiff()}, nil
}
+760
View File
@@ -0,0 +1,760 @@
package service
import (
"context"
"fmt"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/gameutil"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/userdata"
)
var weaponDiffTables = []string{
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserMaterial",
"IUserConsumableItem",
"IUserWeaponStory",
}
var limitBreakDiffTables = []string{
"IUserWeapon",
"IUserWeaponSkill",
"IUserWeaponAbility",
"IUserMaterial",
"IUserConsumableItem",
"IUserWeaponNote",
}
type WeaponServiceServer struct {
pb.UnimplementedWeaponServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.WeaponCatalog
config *masterdata.GameConfig
}
func NewWeaponServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.WeaponCatalog, config *masterdata.GameConfig) *WeaponServiceServer {
return &WeaponServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
}
func (s *WeaponServiceServer) Protect(ctx context.Context, req *pb.ProtectRequest) (*pb.ProtectResponse, error) {
log.Printf("[WeaponService] Protect: uuids=%v", req.UserWeaponUuid)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, uuid := range req.UserWeaponUuid {
weapon, ok := user.Weapons[uuid]
if !ok {
log.Printf("[WeaponService] Protect: weapon uuid=%s not found", uuid)
continue
}
weapon.IsProtected = true
weapon.LatestVersion = nowMillis
user.Weapons[uuid] = weapon
}
})
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserWeapon"}))
return &pb.ProtectResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) Unprotect(ctx context.Context, req *pb.UnprotectRequest) (*pb.UnprotectResponse, error) {
log.Printf("[WeaponService] Unprotect: uuids=%v", req.UserWeaponUuid)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, uuid := range req.UserWeaponUuid {
weapon, ok := user.Weapons[uuid]
if !ok {
log.Printf("[WeaponService] Unprotect: weapon uuid=%s not found", uuid)
continue
}
weapon.IsProtected = false
weapon.LatestVersion = nowMillis
user.Weapons[uuid] = weapon
}
})
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserWeapon"}))
return &pb.UnprotectResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.EnhanceByMaterialRequest) (*pb.EnhanceByMaterialResponse, error) {
log.Printf("[WeaponService] EnhanceByMaterial: uuid=%s materials=%v", req.UserWeaponUuid, req.Materials)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
weapon, ok := user.Weapons[req.UserWeaponUuid]
if !ok {
log.Printf("[WeaponService] EnhanceByMaterial: weapon uuid=%s not found", req.UserWeaponUuid)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceByMaterial: weapon master id=%d not found", weapon.WeaponId)
return
}
totalExp := int32(0)
totalMaterialCount := int32(0)
for materialId, count := range req.Materials {
mat, ok := s.catalog.Materials[materialId]
if !ok {
log.Printf("[WeaponService] EnhanceByMaterial: material id=%d not found, skipping", materialId)
continue
}
cur := user.Materials[materialId]
if cur < count {
log.Printf("[WeaponService] EnhanceByMaterial: insufficient material id=%d have=%d need=%d", materialId, cur, count)
continue
}
user.Materials[materialId] = cur - count
totalMaterialCount += count
expPerUnit := mat.EffectValue
if mat.WeaponType != 0 && mat.WeaponType == wm.WeaponType {
expPerUnit = expPerUnit * s.config.MaterialSameWeaponExpCoefficientPermil / 1000
}
totalExp += expPerUnit * count
}
if costFunc, ok := s.catalog.GoldCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] EnhanceByMaterial: gold cost=%d (materials=%d)", goldCost, totalMaterialCount)
}
weapon.Exp += totalExp
if thresholds, ok := s.catalog.ExpByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds)
}
weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon
log.Printf("[WeaponService] EnhanceByMaterial: weaponId=%d +%d exp -> total=%d level=%d", weapon.WeaponId, totalExp, weapon.Exp, weapon.Level)
})
if err != nil {
return nil, fmt.Errorf("weapon enhance by material: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables))
return &pb.EnhanceByMaterialResponse{
IsGreatSuccess: false,
SurplusEnhanceMaterial: map[int32]int32{},
DiffUserData: diff,
}, nil
}
func (s *WeaponServiceServer) Sell(ctx context.Context, req *pb.SellRequest) (*pb.SellResponse, error) {
log.Printf("[WeaponService] Sell: uuids=%v", req.UserWeaponUuid)
userId := currentUserId(ctx, s.users, s.sessions)
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}).
Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}).
Track("IUserWeaponAbility", oldUser, userdata.SortedWeaponAbilityRecords, []string{"userId", "userWeaponUuid", "slotNumber"})
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
totalGold := int32(0)
for _, uuid := range req.UserWeaponUuid {
weapon, ok := user.Weapons[uuid]
if !ok {
log.Printf("[WeaponService] Sell: weapon uuid=%s not found, skipping", uuid)
continue
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] Sell: weapon master id=%d not found, skipping", weapon.WeaponId)
continue
}
if sellFunc, ok := s.catalog.SellPriceByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
totalGold += sellFunc.Evaluate(weapon.Level)
}
if medals, ok := s.catalog.MedalsByWeaponId[weapon.WeaponId]; ok {
for itemId, count := range medals {
user.ConsumableItems[itemId] += count
}
}
delete(user.Weapons, uuid)
delete(user.WeaponSkills, uuid)
delete(user.WeaponAbilities, uuid)
}
if totalGold > 0 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold
log.Printf("[WeaponService] Sell: granted %d gold", totalGold)
}
})
if err != nil {
return nil, fmt.Errorf("weapon sell: %w", err)
}
sellDiffTables := []string{"IUserWeapon", "IUserWeaponSkill", "IUserWeaponAbility", "IUserConsumableItem"}
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), sellDiffTables)
diff := tracker.Apply(snapshot, tables)
return &pb.SellResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) Evolve(ctx context.Context, req *pb.EvolveRequest) (*pb.EvolveResponse, error) {
log.Printf("[WeaponService] Evolve: uuid=%s", req.UserWeaponUuid)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
weapon, ok := user.Weapons[req.UserWeaponUuid]
if !ok {
log.Printf("[WeaponService] Evolve: weapon uuid=%s not found", req.UserWeaponUuid)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] Evolve: weapon master id=%d not found", weapon.WeaponId)
return
}
evolvedId, ok := s.catalog.EvolutionNextWeaponId[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] Evolve: no evolution for weaponId=%d", weapon.WeaponId)
return
}
totalMaterialCount := int32(0)
mats := s.catalog.EvolutionMaterials[wm.WeaponEvolutionMaterialGroupId]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
if cur < cost {
log.Printf("[WeaponService] Evolve: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost)
cost = cur
}
user.Materials[mat.MaterialId] = cur - cost
totalMaterialCount += cost
}
if costFunc, ok := s.catalog.EvolutionCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] Evolve: gold cost=%d", goldCost)
}
weapon.WeaponId = evolvedId
weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon
evolvedMaster, ok := s.catalog.Weapons[evolvedId]
if ok {
if slots, ok := s.catalog.AbilitySlots[evolvedMaster.WeaponAbilityGroupId]; ok {
abilities := make([]store.WeaponAbilityState, len(slots))
for i, slot := range slots {
abilities[i] = store.WeaponAbilityState{
UserWeaponUuid: req.UserWeaponUuid,
SlotNumber: slot,
Level: 1,
}
}
user.WeaponAbilities[req.UserWeaponUuid] = abilities
}
}
log.Printf("[WeaponService] Evolve: weaponId %d -> %d", wm.WeaponId, evolvedId)
s.checkEvolutionStoryUnlocks(user, evolvedId, nowMillis)
})
if err != nil {
return nil, fmt.Errorf("weapon evolve: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables))
return &pb.EvolveResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceSkillRequest) (*pb.EnhanceSkillResponse, error) {
log.Printf("[WeaponService] EnhanceSkill: uuid=%s skillId=%d addLevel=%d", req.UserWeaponUuid, req.SkillId, req.AddLevelCount)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
weapon, ok := user.Weapons[req.UserWeaponUuid]
if !ok {
log.Printf("[WeaponService] EnhanceSkill: weapon uuid=%s not found", req.UserWeaponUuid)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceSkill: weapon master id=%d not found", weapon.WeaponId)
return
}
groupRows := s.catalog.SkillGroupsByGroupId[wm.WeaponSkillGroupId]
var skillGroup *masterdata.WeaponSkillGroupRow
for i := range groupRows {
if groupRows[i].SkillId == req.SkillId {
skillGroup = &groupRows[i]
break
}
}
if skillGroup == nil {
log.Printf("[WeaponService] EnhanceSkill: skillId=%d not found in group=%d", req.SkillId, wm.WeaponSkillGroupId)
return
}
skills := user.WeaponSkills[req.UserWeaponUuid]
skillIdx := -1
for i, sk := range skills {
if sk.SlotNumber == skillGroup.SlotNumber {
skillIdx = i
break
}
}
if skillIdx < 0 {
log.Printf("[WeaponService] EnhanceSkill: slot=%d not found for weapon uuid=%s", skillGroup.SlotNumber, req.UserWeaponUuid)
return
}
maxLevelFunc, ok := s.catalog.SkillMaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]
if !ok {
log.Printf("[WeaponService] EnhanceSkill: no max skill level func for enhanceId=%d", wm.WeaponSpecificEnhanceId)
return
}
maxLevel := maxLevelFunc.Evaluate(weapon.LimitBreakCount)
currentLevel := skills[skillIdx].Level
addCount := req.AddLevelCount
if currentLevel+addCount > maxLevel {
addCount = maxLevel - currentLevel
}
if addCount <= 0 {
log.Printf("[WeaponService] EnhanceSkill: already at max level %d", currentLevel)
return
}
enhanceMatId := skillGroup.WeaponSkillEnhancementMaterialId
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
key := [2]int32{enhanceMatId, lvl}
mats := s.catalog.SkillEnhanceMats[key]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
if cur < cost {
log.Printf("[WeaponService] EnhanceSkill: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost)
cost = cur
}
user.Materials[mat.MaterialId] = cur - cost
}
if costFunc, ok := s.catalog.SkillCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
goldCost := costFunc.Evaluate(lvl + 1)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
}
}
skills[skillIdx].Level = currentLevel + addCount
user.WeaponSkills[req.UserWeaponUuid] = skills
log.Printf("[WeaponService] EnhanceSkill: skillId=%d level %d -> %d", req.SkillId, currentLevel, skills[skillIdx].Level)
weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon
})
if err != nil {
return nil, fmt.Errorf("weapon enhance skill: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables))
return &pb.EnhanceSkillResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.EnhanceAbilityRequest) (*pb.EnhanceAbilityResponse, error) {
log.Printf("[WeaponService] EnhanceAbility: uuid=%s abilityId=%d addLevel=%d", req.UserWeaponUuid, req.AbilityId, req.AddLevelCount)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
weapon, ok := user.Weapons[req.UserWeaponUuid]
if !ok {
log.Printf("[WeaponService] EnhanceAbility: weapon uuid=%s not found", req.UserWeaponUuid)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceAbility: weapon master id=%d not found", weapon.WeaponId)
return
}
groupRows := s.catalog.AbilityGroupsByGroupId[wm.WeaponAbilityGroupId]
var abilityGroup *masterdata.WeaponAbilityGroupRow
for i := range groupRows {
if groupRows[i].AbilityId == req.AbilityId {
abilityGroup = &groupRows[i]
break
}
}
if abilityGroup == nil {
log.Printf("[WeaponService] EnhanceAbility: abilityId=%d not found in group=%d", req.AbilityId, wm.WeaponAbilityGroupId)
return
}
abilities := user.WeaponAbilities[req.UserWeaponUuid]
abilityIdx := -1
for i, ab := range abilities {
if ab.SlotNumber == abilityGroup.SlotNumber {
abilityIdx = i
break
}
}
if abilityIdx < 0 {
log.Printf("[WeaponService] EnhanceAbility: slot=%d not found for weapon uuid=%s", abilityGroup.SlotNumber, req.UserWeaponUuid)
return
}
maxLevelFunc, ok := s.catalog.AbilityMaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]
if !ok {
log.Printf("[WeaponService] EnhanceAbility: no max ability level func for enhanceId=%d", wm.WeaponSpecificEnhanceId)
return
}
maxLevel := maxLevelFunc.Evaluate(weapon.LimitBreakCount)
currentLevel := abilities[abilityIdx].Level
addCount := req.AddLevelCount
if currentLevel+addCount > maxLevel {
addCount = maxLevel - currentLevel
}
if addCount <= 0 {
log.Printf("[WeaponService] EnhanceAbility: already at max level %d", currentLevel)
return
}
enhanceMatId := abilityGroup.WeaponAbilityEnhancementMaterialId
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
key := [2]int32{enhanceMatId, lvl}
mats := s.catalog.AbilityEnhanceMats[key]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
if cur < cost {
log.Printf("[WeaponService] EnhanceAbility: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost)
cost = cur
}
user.Materials[mat.MaterialId] = cur - cost
}
if costFunc, ok := s.catalog.AbilityCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
goldCost := costFunc.Evaluate(lvl + 1)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
}
}
abilities[abilityIdx].Level = currentLevel + addCount
user.WeaponAbilities[req.UserWeaponUuid] = abilities
log.Printf("[WeaponService] EnhanceAbility: abilityId=%d level %d -> %d", req.AbilityId, currentLevel, abilities[abilityIdx].Level)
weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon
})
if err != nil {
return nil, fmt.Errorf("weapon enhance ability: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables))
return &pb.EnhanceAbilityResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) LimitBreakByMaterial(ctx context.Context, req *pb.LimitBreakByMaterialRequest) (*pb.LimitBreakByMaterialResponse, error) {
log.Printf("[WeaponService] LimitBreakByMaterial: uuid=%s materials=%v", req.UserWeaponUuid, req.Materials)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
weapon, ok := user.Weapons[req.UserWeaponUuid]
if !ok {
log.Printf("[WeaponService] LimitBreakByMaterial: weapon uuid=%s not found", req.UserWeaponUuid)
return
}
if weapon.LimitBreakCount >= s.config.WeaponLimitBreakAvailableCount {
log.Printf("[WeaponService] LimitBreakByMaterial: already at max limit break %d", weapon.LimitBreakCount)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] LimitBreakByMaterial: weapon master id=%d not found", weapon.WeaponId)
return
}
remaining := s.config.WeaponLimitBreakAvailableCount - weapon.LimitBreakCount
totalMaterialCount := int32(0)
for materialId, count := range req.Materials {
if totalMaterialCount >= remaining {
break
}
if count > remaining-totalMaterialCount {
count = remaining - totalMaterialCount
}
cur := user.Materials[materialId]
if cur < count {
log.Printf("[WeaponService] LimitBreakByMaterial: insufficient material id=%d have=%d need=%d", materialId, cur, count)
count = cur
}
user.Materials[materialId] = cur - count
totalMaterialCount += count
}
if costFunc, ok := s.catalog.LimitBreakCostByMaterialByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] LimitBreakByMaterial: gold cost=%d", goldCost)
}
weapon.LimitBreakCount += totalMaterialCount
weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon
note := user.WeaponNotes[weapon.WeaponId]
if note.MaxLimitBreakCount < weapon.LimitBreakCount {
note.MaxLimitBreakCount = weapon.LimitBreakCount
note.LatestVersion = nowMillis
user.WeaponNotes[weapon.WeaponId] = note
}
log.Printf("[WeaponService] LimitBreakByMaterial: weaponId=%d limitBreak -> %d", weapon.WeaponId, weapon.LimitBreakCount)
})
if err != nil {
return nil, fmt.Errorf("weapon limit break by material: %w", err)
}
tables := userdata.FullClientTableMap(snapshot)
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, limitBreakDiffTables))
return &pb.LimitBreakByMaterialResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.LimitBreakByWeaponRequest) (*pb.LimitBreakByWeaponResponse, error) {
log.Printf("[WeaponService] LimitBreakByWeapon: uuid=%s materialUuids=%v", req.UserWeaponUuid, req.MaterialUserWeaponUuids)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}).
Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}).
Track("IUserWeaponAbility", oldUser, userdata.SortedWeaponAbilityRecords, []string{"userId", "userWeaponUuid", "slotNumber"})
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
weapon, ok := user.Weapons[req.UserWeaponUuid]
if !ok {
log.Printf("[WeaponService] LimitBreakByWeapon: weapon uuid=%s not found", req.UserWeaponUuid)
return
}
if weapon.LimitBreakCount >= s.config.WeaponLimitBreakAvailableCount {
log.Printf("[WeaponService] LimitBreakByWeapon: already at max limit break %d", weapon.LimitBreakCount)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] LimitBreakByWeapon: weapon master id=%d not found", weapon.WeaponId)
return
}
remaining := s.config.WeaponLimitBreakAvailableCount - weapon.LimitBreakCount
consumedCount := int32(0)
for _, uuid := range req.MaterialUserWeaponUuids {
if consumedCount >= remaining {
break
}
matWeapon, ok := user.Weapons[uuid]
if !ok {
log.Printf("[WeaponService] LimitBreakByWeapon: material weapon uuid=%s not found, skipping", uuid)
continue
}
if medals, ok := s.catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
for itemId, count := range medals {
user.ConsumableItems[itemId] += count
}
}
delete(user.Weapons, uuid)
delete(user.WeaponSkills, uuid)
delete(user.WeaponAbilities, uuid)
consumedCount++
}
if costFunc, ok := s.catalog.LimitBreakCostByWeaponByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && consumedCount > 0 {
goldCost := costFunc.Evaluate(consumedCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] LimitBreakByWeapon: gold cost=%d", goldCost)
}
weapon.LimitBreakCount += consumedCount
weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon
note := user.WeaponNotes[weapon.WeaponId]
if note.MaxLimitBreakCount < weapon.LimitBreakCount {
note.MaxLimitBreakCount = weapon.LimitBreakCount
note.LatestVersion = nowMillis
user.WeaponNotes[weapon.WeaponId] = note
}
log.Printf("[WeaponService] LimitBreakByWeapon: weaponId=%d limitBreak -> %d (consumed %d weapons)", weapon.WeaponId, weapon.LimitBreakCount, consumedCount)
})
if err != nil {
return nil, fmt.Errorf("weapon limit break by weapon: %w", err)
}
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), limitBreakDiffTables)
diff := tracker.Apply(snapshot, tables)
return &pb.LimitBreakByWeaponResponse{DiffUserData: diff}, nil
}
func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.EnhanceByWeaponRequest) (*pb.EnhanceByWeaponResponse, error) {
log.Printf("[WeaponService] EnhanceByWeapon: uuid=%s materialUuids=%v", req.UserWeaponUuid, req.MaterialUserWeaponUuids)
userId := currentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
oldUser, _ := s.users.SnapshotUser(userId)
tracker := userdata.NewDeleteTracker().
Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}).
Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}).
Track("IUserWeaponAbility", oldUser, userdata.SortedWeaponAbilityRecords, []string{"userId", "userWeaponUuid", "slotNumber"})
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
weapon, ok := user.Weapons[req.UserWeaponUuid]
if !ok {
log.Printf("[WeaponService] EnhanceByWeapon: weapon uuid=%s not found", req.UserWeaponUuid)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceByWeapon: weapon master id=%d not found", weapon.WeaponId)
return
}
totalExp := int32(0)
consumedCount := int32(0)
for _, uuid := range req.MaterialUserWeaponUuids {
matWeapon, ok := user.Weapons[uuid]
if !ok {
log.Printf("[WeaponService] EnhanceByWeapon: material weapon uuid=%s not found, skipping", uuid)
continue
}
matMaster, ok := s.catalog.Weapons[matWeapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceByWeapon: material weapon master id=%d not found, skipping", matWeapon.WeaponId)
continue
}
baseExp := s.catalog.BaseExpByEnhanceId[matMaster.WeaponSpecificEnhanceId]
if matMaster.WeaponType != 0 && matMaster.WeaponType == wm.WeaponType {
baseExp = baseExp * s.config.MaterialSameWeaponExpCoefficientPermil / 1000
}
totalExp += baseExp
if medals, ok := s.catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
for itemId, count := range medals {
user.ConsumableItems[itemId] += count
}
}
delete(user.Weapons, uuid)
delete(user.WeaponSkills, uuid)
delete(user.WeaponAbilities, uuid)
consumedCount++
}
if costFunc, ok := s.catalog.EnhanceCostByWeaponByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && consumedCount > 0 {
goldCost := costFunc.Evaluate(consumedCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] EnhanceByWeapon: gold cost=%d (weapons=%d)", goldCost, consumedCount)
}
weapon.Exp += totalExp
if thresholds, ok := s.catalog.ExpByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds)
}
weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon
log.Printf("[WeaponService] EnhanceByWeapon: weaponId=%d +%d exp -> total=%d level=%d", weapon.WeaponId, totalExp, weapon.Exp, weapon.Level)
})
if err != nil {
return nil, fmt.Errorf("weapon enhance by weapon: %w", err)
}
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), weaponDiffTables)
diff := tracker.Apply(snapshot, tables)
return &pb.EnhanceByWeaponResponse{
IsGreatSuccess: false,
SurplusEnhanceWeapon: []string{},
DiffUserData: diff,
}, nil
}
func (s *WeaponServiceServer) checkEvolutionStoryUnlocks(user *store.UserState, weaponId int32, nowMillis int64) {
wm, ok := s.catalog.Weapons[weaponId]
if !ok || wm.WeaponStoryReleaseConditionGroupId == 0 {
return
}
evoOrder, hasEvo := s.catalog.EvolutionOrder[weaponId]
conditions := s.catalog.ReleaseConditionsByGroupId[wm.WeaponStoryReleaseConditionGroupId]
for _, cond := range conditions {
switch cond.WeaponStoryReleaseConditionType {
case model.WeaponStoryReleaseConditionTypeReachSpecifiedEvolutionCount:
if hasEvo && evoOrder >= cond.ConditionValue {
store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
}
case model.WeaponStoryReleaseConditionTypeAcquisition:
store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
}
}
}
+325
View File
@@ -0,0 +1,325 @@
package store
import (
"fmt"
"log"
"sort"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
)
func DeductPrice(user *UserState, priceType, priceId, amount int32) error {
switch priceType {
case model.PriceTypeConsumableItem:
cur := user.ConsumableItems[priceId]
if cur < amount {
return fmt.Errorf("insufficient consumable %d: have %d, need %d", priceId, cur, amount)
}
user.ConsumableItems[priceId] = cur - amount
case model.PriceTypeGem:
total := user.Gem.FreeGem + user.Gem.PaidGem
if total < amount {
return fmt.Errorf("insufficient gems: have %d, need %d", total, amount)
}
if user.Gem.FreeGem >= amount {
user.Gem.FreeGem -= amount
} else {
amount -= user.Gem.FreeGem
user.Gem.FreeGem = 0
user.Gem.PaidGem -= amount
}
case model.PriceTypePaidGem:
if user.Gem.PaidGem < amount {
return fmt.Errorf("insufficient paid gems: have %d, need %d", user.Gem.PaidGem, amount)
}
user.Gem.PaidGem -= amount
case model.PriceTypePlatformPayment:
// real-money purchase -- treat as free on private server
default:
log.Printf("[DeductPrice] unhandled priceType=%d priceId=%d amount=%d", priceType, priceId, amount)
}
return nil
}
func DeductPossession(user *UserState, possessionType model.PossessionType, possessionId, count int32) {
switch possessionType {
case model.PossessionTypeMaterial:
user.Materials[possessionId] -= count
if user.Materials[possessionId] <= 0 {
delete(user.Materials, possessionId)
}
case model.PossessionTypeConsumableItem:
user.ConsumableItems[possessionId] -= count
if user.ConsumableItems[possessionId] <= 0 {
delete(user.ConsumableItems, possessionId)
}
case model.PossessionTypePaidGem:
user.Gem.PaidGem -= count
case model.PossessionTypeFreeGem:
user.Gem.FreeGem -= count
default:
log.Printf("[DeductPossession] unhandled type=%d id=%d count=%d", possessionType, possessionId, count)
}
}
func GrantPossession(user *UserState, possessionType model.PossessionType, possessionId, count int32) {
switch possessionType {
case model.PossessionTypeMaterial:
user.Materials[possessionId] += count
case model.PossessionTypeConsumableItem:
user.ConsumableItems[possessionId] += count
case model.PossessionTypePaidGem:
user.Gem.PaidGem += count
case model.PossessionTypeFreeGem:
user.Gem.FreeGem += count
case model.PossessionTypeImportantItem:
user.ImportantItems[possessionId] += count
case model.PossessionTypePremiumItem:
user.PremiumItems[possessionId] = gametime.NowMillis()
default:
log.Printf("[GrantPossession] unhandled type=%d id=%d count=%d", possessionType, possessionId, count)
}
}
type CostumeRef struct {
CharacterId int32
}
type WeaponRef struct {
WeaponSkillGroupId int32
WeaponAbilityGroupId int32
WeaponStoryReleaseConditionGroupId int32
}
type WeaponStoryReleaseCond struct {
StoryIndex int32
WeaponStoryReleaseConditionType model.WeaponStoryReleaseConditionType
ConditionValue int32
}
type PossessionGranter struct {
CostumeById map[int32]CostumeRef
WeaponById map[int32]WeaponRef
WeaponSkillSlots map[int32][]int32
WeaponAbilitySlots map[int32][]int32
ReleaseConditions map[int32][]WeaponStoryReleaseCond
}
func (g *PossessionGranter) GrantFull(user *UserState, possessionType model.PossessionType, possessionId, count int32, nowMillis int64) {
switch possessionType {
case model.PossessionTypeCostume, model.PossessionTypeCostumeEnhanced:
g.GrantCostume(user, possessionId, nowMillis)
case model.PossessionTypeWeapon, model.PossessionTypeWeaponEnhanced:
g.GrantWeapon(user, possessionId, nowMillis)
default:
GrantPossession(user, possessionType, possessionId, count)
}
}
func (g *PossessionGranter) GrantCostume(user *UserState, costumeId int32, nowMillis int64) {
for _, row := range user.Costumes {
if row.CostumeId == costumeId {
return
}
}
if cm, ok := g.CostumeById[costumeId]; ok {
if _, exists := user.Characters[cm.CharacterId]; !exists {
user.Characters[cm.CharacterId] = CharacterState{
CharacterId: cm.CharacterId,
Level: 1,
}
}
}
key := fmt.Sprintf("reward-costume-%d", costumeId)
user.Costumes[key] = CostumeState{
UserCostumeUuid: key,
CostumeId: costumeId,
Level: 1,
HeadupDisplayViewId: 1,
AcquisitionDatetime: nowMillis,
}
user.CostumeActiveSkills[key] = CostumeActiveSkillState{
UserCostumeUuid: key,
Level: 1,
AcquisitionDatetime: nowMillis,
}
}
func (g *PossessionGranter) GrantWeapon(user *UserState, weaponId int32, nowMillis int64) {
key := fmt.Sprintf("reward-weapon-%d-%d", weaponId, nowMillis)
user.Weapons[key] = WeaponState{
UserWeaponUuid: key,
WeaponId: weaponId,
Level: 1,
AcquisitionDatetime: nowMillis,
}
if _, exists := user.WeaponNotes[weaponId]; !exists {
user.WeaponNotes[weaponId] = WeaponNoteState{
WeaponId: weaponId,
MaxLevel: 1,
MaxLimitBreakCount: 0,
FirstAcquisitionDatetime: nowMillis,
LatestVersion: nowMillis,
}
}
weapon, ok := g.WeaponById[weaponId]
if !ok {
return
}
g.populateWeaponSkillsAbilities(user, key, weapon)
if weapon.WeaponStoryReleaseConditionGroupId != 0 {
for _, cond := range g.ReleaseConditions[weapon.WeaponStoryReleaseConditionGroupId] {
switch cond.WeaponStoryReleaseConditionType {
case model.WeaponStoryReleaseConditionTypeAcquisition:
grantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
case model.WeaponStoryReleaseConditionTypeQuestClear:
if qs, ok := user.Quests[cond.ConditionValue]; ok && qs.QuestStateType == model.UserQuestStateTypeCleared {
grantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
}
}
}
}
}
func (g *PossessionGranter) populateWeaponSkillsAbilities(user *UserState, weaponUuid string, weapon WeaponRef) {
if slots, ok := g.WeaponSkillSlots[weapon.WeaponSkillGroupId]; ok {
skills := make([]WeaponSkillState, len(slots))
for i, slot := range slots {
skills[i] = WeaponSkillState{
UserWeaponUuid: weaponUuid,
SlotNumber: slot,
Level: 1,
}
}
user.WeaponSkills[weaponUuid] = skills
}
if slots, ok := g.WeaponAbilitySlots[weapon.WeaponAbilityGroupId]; ok {
abilities := make([]WeaponAbilityState, len(slots))
for i, slot := range slots {
abilities[i] = WeaponAbilityState{
UserWeaponUuid: weaponUuid,
SlotNumber: slot,
Level: 1,
}
}
user.WeaponAbilities[weaponUuid] = abilities
}
}
func GrantWeaponStoryUnlock(user *UserState, weaponId, storyIndex int32, nowMillis int64) {
grantWeaponStoryUnlock(user, weaponId, storyIndex, nowMillis)
}
func grantWeaponStoryUnlock(user *UserState, weaponId, storyIndex int32, nowMillis int64) {
hasWeapon := false
for _, row := range user.Weapons {
if row.WeaponId == weaponId {
hasWeapon = true
break
}
}
if !hasWeapon {
log.Printf("[grantWeaponStoryUnlock] skipping weaponId=%d (weapon not in user.Weapons)", weaponId)
return
}
if user.WeaponStories == nil {
user.WeaponStories = make(map[int32]WeaponStoryState)
}
cur := user.WeaponStories[weaponId]
if storyIndex <= cur.ReleasedMaxStoryIndex {
return
}
user.WeaponStories[weaponId] = WeaponStoryState{
WeaponId: weaponId,
ReleasedMaxStoryIndex: storyIndex,
LatestVersion: nowMillis,
}
}
func EnsureDefaultDeck(user *UserState, nowMillis int64) {
if len(user.Costumes) == 0 || len(user.Decks) > 0 {
return
}
costumeUuid := FirstSortedKey(user.Costumes)
weaponUuid := FirstSortedKey(user.Weapons)
companionUuid := FirstSortedKey(user.Companions)
dcUuid := "default-deck-character-0001"
user.DeckCharacters[dcUuid] = DeckCharacterState{
UserDeckCharacterUuid: dcUuid,
UserCostumeUuid: costumeUuid,
MainUserWeaponUuid: weaponUuid,
UserCompanionUuid: companionUuid,
Power: 100,
LatestVersion: nowMillis,
}
user.Decks[DeckKey{DeckType: model.DeckTypeQuest, UserDeckNumber: 1}] = DeckState{
DeckType: model.DeckTypeQuest,
UserDeckNumber: 1,
UserDeckCharacterUuid01: dcUuid,
Name: "Deck 1",
Power: 100,
LatestVersion: nowMillis,
}
if _, exists := user.DeckTypeNotes[model.DeckTypeQuest]; !exists {
user.DeckTypeNotes[model.DeckTypeQuest] = DeckTypeNoteState{
DeckType: model.DeckTypeQuest,
MaxDeckPower: 100,
LatestVersion: nowMillis,
}
}
}
func FirstSortedKey[V any](m map[string]V) string {
if len(m) == 0 {
return ""
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys[0]
}
func ApplyDeckReplacement(user *UserState, deckType model.DeckType, userDeckNumber int32, slots []DeckCharacterInput, nowMillis int64) {
deckKey := DeckKey{DeckType: deckType, UserDeckNumber: userDeckNumber}
deck := user.Decks[deckKey]
deck.DeckType = deckType
deck.UserDeckNumber = userDeckNumber
if deck.Name == "" {
deck.Name = fmt.Sprintf("Deck %d", userDeckNumber)
}
if deck.Power == 0 {
deck.Power = 100
}
uuids := []*string{&deck.UserDeckCharacterUuid01, &deck.UserDeckCharacterUuid02, &deck.UserDeckCharacterUuid03}
for i, uuid := range uuids {
if i >= len(slots) || slots[i].UserCostumeUuid == "" {
*uuid = ""
continue
}
slot := slots[i]
dcUuid := fmt.Sprintf("deck-%d-%d-%d", deckType, userDeckNumber, i+1)
dc := user.DeckCharacters[dcUuid]
dc.UserDeckCharacterUuid = dcUuid
dc.UserCostumeUuid = slot.UserCostumeUuid
dc.MainUserWeaponUuid = slot.MainUserWeaponUuid
dc.UserCompanionUuid = slot.UserCompanionUuid
dc.UserThoughtUuid = slot.UserThoughtUuid
dc.DressupCostumeId = slot.DressupCostumeId
dc.LatestVersion = nowMillis
user.DeckCharacters[dcUuid] = dc
user.DeckSubWeapons[dcUuid] = slot.SubWeaponUuids
user.DeckParts[dcUuid] = slot.PartsUuids
*uuid = dcUuid
}
deck.LatestVersion = nowMillis
user.Decks[deckKey] = deck
}
+32
View File
@@ -0,0 +1,32 @@
package store
import (
"fmt"
"strconv"
"strings"
)
func marshalKey(vals ...int64) []byte {
b := strconv.AppendInt(nil, vals[0], 10)
for _, v := range vals[1:] {
b = append(b, ':')
b = strconv.AppendInt(b, v, 10)
}
return b
}
func unmarshalKey(text []byte, name string, n int) ([]int64, error) {
parts := strings.Split(string(text), ":")
if len(parts) != n {
return nil, fmt.Errorf("invalid %s: %s", name, text)
}
out := make([]int64, n)
for i, p := range parts {
v, err := strconv.ParseInt(p, 10, 64)
if err != nil {
return nil, err
}
out[i] = v
}
return out, nil
}
+149
View File
@@ -0,0 +1,149 @@
package memory
import (
"maps"
"lunar-tear/server/internal/store"
)
func cloneUserState(u store.UserState) store.UserState {
out := u
out.Tutorials = maps.Clone(u.Tutorials)
out.Characters = maps.Clone(u.Characters)
out.Costumes = maps.Clone(u.Costumes)
out.Weapons = maps.Clone(u.Weapons)
out.Companions = maps.Clone(u.Companions)
out.Thoughts = maps.Clone(u.Thoughts)
out.DeckCharacters = maps.Clone(u.DeckCharacters)
out.DeckSubWeapons = maps.Clone(u.DeckSubWeapons)
out.DeckParts = cloneSliceMap(u.DeckParts)
out.Decks = maps.Clone(u.Decks)
out.Quests = maps.Clone(u.Quests)
out.QuestMissions = maps.Clone(u.QuestMissions)
out.WeaponStories = maps.Clone(u.WeaponStories)
out.Missions = maps.Clone(u.Missions)
out.Gimmick = store.GimmickState{
Progress: maps.Clone(u.Gimmick.Progress),
OrnamentProgress: maps.Clone(u.Gimmick.OrnamentProgress),
Sequences: maps.Clone(u.Gimmick.Sequences),
Unlocks: maps.Clone(u.Gimmick.Unlocks),
}
out.CageOrnamentRewards = maps.Clone(u.CageOrnamentRewards)
out.ConsumableItems = maps.Clone(u.ConsumableItems)
out.Materials = maps.Clone(u.Materials)
out.Parts = maps.Clone(u.Parts)
out.PartsGroupNotes = maps.Clone(u.PartsGroupNotes)
out.PartsPresets = maps.Clone(u.PartsPresets)
out.ImportantItems = maps.Clone(u.ImportantItems)
out.CostumeActiveSkills = maps.Clone(u.CostumeActiveSkills)
out.WeaponSkills = cloneSliceMap(u.WeaponSkills)
out.WeaponAbilities = cloneSliceMap(u.WeaponAbilities)
out.DeckTypeNotes = maps.Clone(u.DeckTypeNotes)
out.WeaponNotes = maps.Clone(u.WeaponNotes)
out.NaviCutInPlayed = maps.Clone(u.NaviCutInPlayed)
out.ViewedMovies = maps.Clone(u.ViewedMovies)
out.ContentsStories = maps.Clone(u.ContentsStories)
out.DrawnOmikuji = maps.Clone(u.DrawnOmikuji)
out.PremiumItems = maps.Clone(u.PremiumItems)
out.DokanConfirmed = maps.Clone(u.DokanConfirmed)
out.ShopItems = maps.Clone(u.ShopItems)
out.ShopReplaceableLineup = maps.Clone(u.ShopReplaceableLineup)
out.Explore = u.Explore
out.ExploreScores = maps.Clone(u.ExploreScores)
out.Gacha = store.GachaState{
RewardAvailable: u.Gacha.RewardAvailable,
TodaysCurrentDrawCount: u.Gacha.TodaysCurrentDrawCount,
DailyMaxCount: u.Gacha.DailyMaxCount,
LastRewardDrawDate: u.Gacha.LastRewardDrawDate,
ConvertedGachaMedal: store.ConvertedGachaMedalState{
ConvertedMedalPossession: append([]store.ConsumableItemState(nil), u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession...),
ObtainPossession: cloneConsumableItemPtr(u.Gacha.ConvertedGachaMedal.ObtainPossession),
},
BannerStates: cloneBannerStates(u.Gacha.BannerStates),
}
out.Gifts = store.GiftState{
NotReceived: cloneNotReceivedGifts(u.Gifts.NotReceived),
Received: cloneReceivedGifts(u.Gifts.Received),
}
out.Battle = u.Battle
out.CostumeAwakenStatusUps = maps.Clone(u.CostumeAwakenStatusUps)
out.AutoSaleSettings = maps.Clone(u.AutoSaleSettings)
out.CharacterRebirths = maps.Clone(u.CharacterRebirths)
return out
}
func cloneGachaCatalogEntry(entry store.GachaCatalogEntry) store.GachaCatalogEntry {
out := entry
out.PricePhases = append([]store.GachaPricePhaseEntry(nil), entry.PricePhases...)
out.PromotionItems = append([]store.GachaPromotionItem(nil), entry.PromotionItems...)
return out
}
func cloneBannerStates(m map[int32]store.GachaBannerState) map[int32]store.GachaBannerState {
if m == nil {
return nil
}
out := make(map[int32]store.GachaBannerState, len(m))
for k, v := range m {
bs := v
bs.BoxDrewCounts = maps.Clone(v.BoxDrewCounts)
out[k] = bs
}
return out
}
func cloneConsumableItemPtr(item *store.ConsumableItemState) *store.ConsumableItemState {
if item == nil {
return nil
}
out := *item
return &out
}
func cloneNotReceivedGifts(gifts []store.NotReceivedGiftState) []store.NotReceivedGiftState {
out := make([]store.NotReceivedGiftState, len(gifts))
for i, gift := range gifts {
out[i] = store.NotReceivedGiftState{
GiftCommon: store.GiftCommonState{
PossessionType: gift.GiftCommon.PossessionType,
PossessionId: gift.GiftCommon.PossessionId,
Count: gift.GiftCommon.Count,
GrantDatetime: gift.GiftCommon.GrantDatetime,
DescriptionGiftTextId: gift.GiftCommon.DescriptionGiftTextId,
EquipmentData: append([]byte(nil), gift.GiftCommon.EquipmentData...),
},
ExpirationDatetime: gift.ExpirationDatetime,
UserGiftUuid: gift.UserGiftUuid,
}
}
return out
}
func cloneSliceMap[T any](m map[string][]T) map[string][]T {
if m == nil {
return nil
}
out := make(map[string][]T, len(m))
for k, v := range m {
out[k] = append([]T(nil), v...)
}
return out
}
func cloneReceivedGifts(gifts []store.ReceivedGiftState) []store.ReceivedGiftState {
out := make([]store.ReceivedGiftState, len(gifts))
for i, gift := range gifts {
out[i] = store.ReceivedGiftState{
GiftCommon: store.GiftCommonState{
PossessionType: gift.GiftCommon.PossessionType,
PossessionId: gift.GiftCommon.PossessionId,
Count: gift.GiftCommon.Count,
GrantDatetime: gift.GiftCommon.GrantDatetime,
DescriptionGiftTextId: gift.GiftCommon.DescriptionGiftTextId,
EquipmentData: append([]byte(nil), gift.GiftCommon.EquipmentData...),
},
ReceivedDatetime: gift.ReceivedDatetime,
}
}
return out
}
+198
View File
@@ -0,0 +1,198 @@
package memory
import (
"fmt"
"strings"
"sync"
"time"
"lunar-tear/server/internal/store"
)
type Option func(*MemoryStore)
func WithSnapshotDir(dir string) Option {
return func(s *MemoryStore) {
s.snapshotDir = dir
}
}
func WithSceneId(sceneId int32) Option {
return func(s *MemoryStore) {
s.bootstrapSceneId = sceneId
}
}
func WithStarterItems(v bool) Option {
return func(s *MemoryStore) {
s.starterItems = v
}
}
type MemoryStore struct {
mu sync.RWMutex
clock store.Clock
bootstrapSceneId int32
snapshotDir string
starterItems bool
lastSnapshotSceneId int32
nextUserId int64
users map[int64]*store.UserState
userIdsByUuid map[string]int64
sessionToUserId map[string]int64
sessions map[string]store.SessionState
gachaCatalog map[int32]store.GachaCatalogEntry
}
var (
_ store.UserRepository = (*MemoryStore)(nil)
_ store.SessionRepository = (*MemoryStore)(nil)
_ store.GachaRepository = (*MemoryStore)(nil)
)
func New(clock store.Clock, options ...Option) *MemoryStore {
if clock == nil {
clock = time.Now
}
s := &MemoryStore{
clock: clock,
nextUserId: defaultUserId,
users: make(map[int64]*store.UserState),
userIdsByUuid: make(map[string]int64),
sessionToUserId: make(map[string]int64),
sessions: make(map[string]store.SessionState),
gachaCatalog: make(map[int32]store.GachaCatalogEntry),
}
for _, opt := range options {
opt(s)
}
return s
}
func (s *MemoryStore) EnsureUser(uuid string) (store.UserState, error) {
s.mu.Lock()
defer s.mu.Unlock()
return cloneUserState(*s.getOrCreateLocked(normalizeUUID(uuid))), nil
}
func (s *MemoryStore) CreateSession(uuid string, ttl time.Duration) (store.UserState, store.SessionState, error) {
s.mu.Lock()
defer s.mu.Unlock()
user := s.getOrCreateLocked(normalizeUUID(uuid))
now := s.clock()
session := store.SessionState{
SessionKey: fmt.Sprintf("session_%d_%d", user.UserId, now.UnixNano()),
UserId: user.UserId,
Uuid: user.Uuid,
ExpireAt: now.Add(ttl),
}
s.sessionToUserId[session.SessionKey] = user.UserId
s.sessions[session.SessionKey] = session
return cloneUserState(*user), session, nil
}
func (s *MemoryStore) ResolveUserId(sessionKey string) (int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
userId, ok := s.sessionToUserId[sessionKey]
if !ok {
return 0, store.ErrNotFound
}
return userId, nil
}
func (s *MemoryStore) SnapshotUser(userId int64) (store.UserState, error) {
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[userId]
if !ok {
return store.UserState{}, store.ErrNotFound
}
return cloneUserState(*user), nil
}
func (s *MemoryStore) UpdateUser(userId int64, mutate func(*store.UserState)) (store.UserState, error) {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[userId]
if !ok {
return store.UserState{}, store.ErrNotFound
}
mutate(user)
sceneId := user.MainQuest.CurrentQuestSceneId
if s.snapshotDir != "" && sceneId != 0 && sceneId != s.lastSnapshotSceneId {
saveSnapshot(user, s.snapshotDir)
s.lastSnapshotSceneId = sceneId
}
return cloneUserState(*user), nil
}
func (s *MemoryStore) DefaultUserId() (int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if _, ok := s.users[defaultUserId]; ok {
return defaultUserId, nil
}
if len(s.users) == 0 {
return defaultUserId, nil
}
var minUserId int64
for userId := range s.users {
if minUserId == 0 || userId < minUserId {
minUserId = userId
}
}
return minUserId, nil
}
func (s *MemoryStore) SnapshotCatalog() ([]store.GachaCatalogEntry, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]store.GachaCatalogEntry, 0, len(s.gachaCatalog))
for _, entry := range s.gachaCatalog {
out = append(out, cloneGachaCatalogEntry(entry))
}
return out, nil
}
func (s *MemoryStore) ReplaceCatalog(entries []store.GachaCatalogEntry) error {
s.mu.Lock()
defer s.mu.Unlock()
s.gachaCatalog = make(map[int32]store.GachaCatalogEntry, len(entries))
for _, entry := range entries {
s.gachaCatalog[entry.GachaId] = cloneGachaCatalogEntry(entry)
}
return nil
}
func (s *MemoryStore) getOrCreateLocked(uuid string) *store.UserState {
if userId, ok := s.userIdsByUuid[uuid]; ok {
return s.users[userId]
}
userId := s.nextUserId
s.nextUserId++
user := seedUserState(userId, uuid, s.clock().UnixMilli(), s.bootstrapSceneId, s.snapshotDir, s.starterItems)
s.users[userId] = user
s.userIdsByUuid[uuid] = userId
return user
}
func normalizeUUID(uuid string) string {
uuid = strings.TrimSpace(uuid)
if uuid == "" {
return defaultUUID
}
return uuid
}
+222
View File
@@ -0,0 +1,222 @@
package memory
import (
"fmt"
"log"
"time"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
)
const (
defaultUUID = "default-user"
defaultUserId = int64(1001)
starterMissionId = int32(1)
starterMainQuestRouteId = int32(1)
starterMainQuestSeasonId = int32(1)
missionInProgress = int32(1)
giftUUIDPrefix = "default-gift"
defaultBirthYear = int32(2000)
defaultBirthMonth = int32(1)
defaultBackupToken = "mock-backup-token"
defaultChargeMoneyThisMonth = int64(0)
)
type starterItemDef struct {
Type model.PossessionType
Id int32
Qty int32
}
var defaultStarterItems = []starterItemDef{
{Type: model.PossessionTypeFreeGem, Id: 0, Qty: 300},
{Type: model.PossessionTypeConsumableItem, Id: 9001, Qty: 1000},
{Type: model.PossessionTypeConsumableItem, Id: model.ConsumableIdChapterTicket, Qty: 1000},
{Type: model.PossessionTypeConsumableItem, Id: 5001, Qty: 1000},
{Type: model.PossessionTypeConsumableItem, Id: 5002, Qty: 1000},
{Type: model.PossessionTypeConsumableItem, Id: 5003, Qty: 1000},
{Type: model.PossessionTypeConsumableItem, Id: 1009, Qty: 1000},
}
func seedUserState(userId int64, uuid string, nowMillis int64, sceneId int32, snapshotDir string, grantStarterItems bool) *store.UserState {
if sceneId != 0 && snapshotDir != "" {
user, err := loadSnapshot(snapshotDir, sceneId)
if err != nil {
log.Fatalf("[bootstrap] no snapshot for scene=%d: %v", sceneId, err)
}
log.Printf("[bootstrap] loaded snapshot for scene=%d", sceneId)
if grantStarterItems {
applyStarterItems(user)
}
return user
}
user := &store.UserState{
UserId: userId,
Uuid: uuid,
PlayerId: userId,
OsType: 2,
PlatformType: 2,
UserRestrictionType: 0,
RegisterDatetime: nowMillis,
GameStartDatetime: nowMillis,
LatestVersion: 0,
BirthYear: defaultBirthYear,
BirthMonth: defaultBirthMonth,
BackupToken: defaultBackupToken,
ChargeMoneyThisMonth: defaultChargeMoneyThisMonth,
Setting: store.UserSettingState{
IsNotifyPurchaseAlert: false,
LatestVersion: 0,
},
Status: store.UserStatusState{
Level: 1,
Exp: 0,
StaminaMilliValue: 50000,
StaminaUpdateDatetime: nowMillis,
LatestVersion: 0,
},
Gem: store.UserGemState{
PaidGem: 10000,
FreeGem: 10000,
},
Profile: store.UserProfileState{
Name: "",
NameUpdateDatetime: 0,
Message: "",
MessageUpdateDatetime: nowMillis,
FavoriteCostumeId: 0,
FavoriteCostumeIdUpdateDatetime: nowMillis,
LatestVersion: 0,
},
Login: store.UserLoginState{
TotalLoginCount: 1,
ContinualLoginCount: 1,
MaxContinualLoginCount: 1,
LastLoginDatetime: nowMillis,
LastComebackLoginDatetime: 0,
LatestVersion: 0,
},
LoginBonus: store.UserLoginBonusState{
LoginBonusId: 1,
CurrentPageNumber: 1,
CurrentStampNumber: 0,
LatestRewardReceiveDatetime: 0,
LatestVersion: 0,
},
Tutorials: map[int32]store.TutorialProgressState{
1: {TutorialType: 1},
},
Battle: store.BattleState{},
Gifts: store.GiftState{
NotReceived: []store.NotReceivedGiftState{
{
GiftCommon: store.GiftCommonState{
PossessionType: int32(model.PossessionTypeFreeGem),
PossessionId: 0,
Count: 300,
GrantDatetime: nowMillis,
},
ExpirationDatetime: nowMillis + int64((7*24*time.Hour)/time.Millisecond),
UserGiftUuid: fmt.Sprintf("%s-%d-1", giftUUIDPrefix, userId),
},
},
Received: []store.ReceivedGiftState{},
},
Gacha: store.GachaState{
ConvertedGachaMedal: store.ConvertedGachaMedalState{
ConvertedMedalPossession: []store.ConsumableItemState{},
},
BannerStates: make(map[int32]store.GachaBannerState),
},
MainQuest: store.MainQuestState{
CurrentMainQuestRouteId: starterMainQuestRouteId,
MainQuestSeasonId: starterMainQuestSeasonId,
},
Notifications: store.NotificationState{
GiftNotReceiveCount: 1,
},
Characters: make(map[int32]store.CharacterState),
Costumes: make(map[string]store.CostumeState),
Weapons: make(map[string]store.WeaponState),
Companions: make(map[string]store.CompanionState),
DeckCharacters: make(map[string]store.DeckCharacterState),
Decks: make(map[store.DeckKey]store.DeckState),
DeckSubWeapons: make(map[string][]string),
DeckParts: make(map[string][]string),
Quests: make(map[int32]store.UserQuestState),
QuestMissions: make(map[store.QuestMissionKey]store.UserQuestMissionState),
SideStoryQuests: make(map[int32]store.SideStoryQuestProgress),
QuestLimitContentStatus: make(map[int32]store.QuestLimitContentStatus),
BigHuntMaxScores: make(map[int32]store.BigHuntMaxScore),
BigHuntStatuses: make(map[int32]store.BigHuntStatus),
BigHuntScheduleMaxScores: make(map[store.BigHuntScheduleScoreKey]store.BigHuntScheduleMaxScore),
BigHuntWeeklyMaxScores: make(map[store.BigHuntWeeklyScoreKey]store.BigHuntWeeklyMaxScore),
BigHuntWeeklyStatuses: make(map[int64]store.BigHuntWeeklyStatus),
WeaponStories: make(map[int32]store.WeaponStoryState),
Missions: map[int32]store.UserMissionState{
starterMissionId: {
MissionId: starterMissionId,
StartDatetime: nowMillis,
MissionProgressStatusType: missionInProgress,
},
},
Gimmick: store.GimmickState{
Progress: make(map[store.GimmickKey]store.GimmickProgressState),
OrnamentProgress: make(map[store.GimmickOrnamentKey]store.GimmickOrnamentProgressState),
Sequences: make(map[store.GimmickSequenceKey]store.GimmickSequenceState),
Unlocks: make(map[store.GimmickKey]store.GimmickUnlockState),
},
CageOrnamentRewards: make(map[int32]store.CageOrnamentRewardState),
ConsumableItems: make(map[int32]int32),
Materials: make(map[int32]int32),
Thoughts: make(map[string]store.ThoughtState),
Parts: make(map[string]store.PartsState),
PartsGroupNotes: make(map[int32]store.PartsGroupNoteState),
PartsPresets: make(map[int32]store.PartsPresetState),
ImportantItems: make(map[int32]int32),
CostumeActiveSkills: make(map[string]store.CostumeActiveSkillState),
WeaponSkills: make(map[string][]store.WeaponSkillState),
WeaponAbilities: make(map[string][]store.WeaponAbilityState),
DeckTypeNotes: make(map[model.DeckType]store.DeckTypeNoteState),
WeaponNotes: make(map[int32]store.WeaponNoteState),
NaviCutInPlayed: make(map[int32]bool),
ViewedMovies: make(map[int32]int64),
ContentsStories: make(map[int32]int64),
DrawnOmikuji: make(map[int32]int64),
PremiumItems: make(map[int32]int64),
DokanConfirmed: make(map[int32]bool),
ShopItems: make(map[int32]store.UserShopItemState),
ShopReplaceableLineup: make(map[int32]store.UserShopReplaceableLineupState),
ExploreScores: make(map[int32]store.ExploreScoreState),
CharacterBoards: make(map[int32]store.CharacterBoardState),
CharacterBoardAbilities: make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState),
CharacterBoardStatusUps: make(map[store.CharacterBoardStatusUpKey]store.CharacterBoardStatusUpState),
CostumeAwakenStatusUps: make(map[store.CostumeAwakenStatusKey]store.CostumeAwakenStatusUpState),
AutoSaleSettings: make(map[int32]store.AutoSaleSettingState),
CharacterRebirths: make(map[int32]store.CharacterRebirthState),
}
store.EnsureDefaultDeck(user, nowMillis)
if grantStarterItems {
applyStarterItems(user)
}
return user
}
func applyStarterItems(user *store.UserState) {
for _, item := range defaultStarterItems {
switch item.Type {
case model.PossessionTypeFreeGem:
user.Gem.FreeGem += item.Qty
case model.PossessionTypeConsumableItem:
user.ConsumableItems[item.Id] += item.Qty
case model.PossessionTypeMaterial:
user.Materials[item.Id] += item.Qty
}
}
}

Some files were not shown because too many files have changed in this diff Show More