commit 02f511f40c71b1e0921c7a7d49d459bda89ea6f2 Author: Ilya Groshev Date: Tue Apr 14 09:28:26 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e805d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# IDE / Cursor +.cursor/ + +# Build artifacts +server/bin/ +server/tmp/ +server/lunar-tear + +__pycache__/ + +frida/build/ +node_modules/ + +# Go +server/vendor/ + +# Certs (regenerate per-environment) +server/certs/ + +# Server assets (binary data, too large for git) +server/assets/ + +# Snapshots (recorded user state) +snapshots/ +extracted_tables/ +master_data/ +master_data_before_eos/ + +# Client APK and dumps +client/ +tools/ + +# Generated protobuf (regenerate: cd server && make proto) +server/gen/ + +# OS junk +.DS_Store +Thumbs.db + +# Logs +*.log + +frida/ +scripts/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6e0ea33 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ilya Groshev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2101897 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Lunar Tear + +Private server research project for a certain discontinued mobile game. +Discord server: https://discord.gg/G3anrfcV + +## How To Launch The Server + +### Prerequisites + +- Go 1.24+ +- Populated `server/assets/` directory + +### Regenerate protobuf stubs + +```bash +cd server +make proto +``` + +### Run + +```bash +cd server +sudo go run ./cmd/lunar-tear \ + --host 10.0.2.2 \ + --http-port 8080 \ + --scene 13 +``` + +`sudo` is needed because gRPC binds to port 443 (privileged). On Linux you can use `setcap` instead: + +```bash +go build -o lunar-tear ./cmd/lunar-tear +sudo setcap cap_net_bind_service=+ep ./lunar-tear +./lunar-tear --host 10.0.2.2 --http-port 8080 --scene 13 +``` + +### Ports + +| Protocol | Port | Notes | +| -------- | ---- | ---------------------------------------------------- | +| gRPC | 443 | hardcoded by the client, not configurable | +| HTTP | 8080 | Octo asset API + game web pages (`--http-port` flag) | + +### Flags + +| Flag | Default | Description | +| ---------------------- | ------------------- | -------------------------------------------------------- | +| `--host` | `127.0.0.1` | hostname/IP given to the client | +| `--http-port` | `8080` | HTTP/Octo server port | +| `--scene` | `0` | bootstrap new users to scene N (0 = fresh start) | + +## ⚠️ Legal Disclaimer + +**Lunar Tear** is a fan-made, non-commercial **preservation and research project** dedicated to keeping a certain discontinued mobile game playable for educational and archival purposes. + +- This project is **not affiliated with**, **endorsed by**, or **approved by** the original publisher or any of its subsidiaries. +- All trademarks, copyrights, and intellectual property related to the original game and its associated franchises belong to their respective owners. +- All code in this repository is original work developed through clean-room reverse engineering for interoperability with the game client. +- No copyrighted game assets, binaries, or master data are distributed in this repository. + +**Use at your own risk.** The author assumes no liability for any damages or legal consequences that may arise from using this software. By using or contributing to this project, you are solely responsible for ensuring your usage complies with all applicable laws in your jurisdiction. + +This project is released under the [MIT License](LICENSE). + +**If you are a rights holder with concerns regarding this project**, please contact me directly. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..5a5b7ba --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,92 @@ +# Changelog + +## 2026-04-11 + +### Working + +- Memoir enhancement and deck/memoir management updates +- Companion enhancement +- Costume awakening +- Costume ascending +- Character exalt +- Costume skills level up +- Weapon ascending +- Weapon evolution +- Weapon skills level up +- Quest skipping and auto sale settings +- Item shop +- Deck skins +- [MVP] Gacha system +- [MVP] EX Chapter Quests +- [MVP] Subjugation Battles + +### Fixed + +- Retire navigation +- Scene transitions mid new arcs + +## 2026-04-04 + +### Working + +- Weapon management (enhancement with material consumption, skill/ability tracking, protect/unprotect) +- Mythic slab / character board (panel releases, status effects, ability tracking) +- Explore system +- In-app purchase flow +- Friend service stub +- Master data tooling +- Costume max-level capping by rarity in quest reward flow + +### Fixed + +- Map freeze caused by gimmick schedule overflow — capped patched entries under the client's MaxGimmickSequenceSchedule=1024 limit + +### Roadblock + +- Retire quest/battle mechanism — still untraced for quest/battle +- Chapter transition loop — re-login after chapter 7 replays scene 261 instead of advancing + +### Need to Figure Out + +- Banner/gacha logic (scheduling, rates, pity, relationship between MomBanner and gacha catalogs) + +## 2026-03-28 + +### Working + +- Everything from 2026-03-21, plus: +- Costume enhancement (gold cost, material consumption, same-weapon-type EXP bonus) +- Shop (buying items, price deduction, starter item grants on new accounts) +- Mission progress tracking +- 3D viewer +- Event quests (start/finish/restart/update lifecycle, state tracking) +- Tutorial rewards with companion choices +- Battle drop rewards on quest finish +- Snapshot system for saving/loading user state per quest scene + +### Roadblock + +- Retire quest/battle mechanism — the abandon/withdraw flow for quests and battles hasn't been traced or implemented yet + +### Need to Figure Out + +- Rarity/awakening, and full client expectations (enhancement is done, rest TBD) +- Banner/gacha logic (scheduling, rates, pity, relationship between MomBanner and gacha catalogs) + +## 2026-03-21 + +### Working + +- Login and account creation flow (ToS, name entry, graphic settings, title completion) +- Deck configuration +- Cage ornament rewards +- Main quest progression up to the first battle-only quest obstacle + +### Roadblock + +- Battle-only quests — the quest engine handles story-driven quests but pure battle encounters use a different entry/exit path that hasn't been traced yet + +### Need to Figure Out + +- How costumes work in-game (equip rules, stats, rarity/awakening, client expectations) +- Banner/gacha logic (scheduling, rates, pity, relationship between MomBanner and gacha catalogs) diff --git a/server/Makefile b/server/Makefile new file mode 100644 index 0000000..60ea468 --- /dev/null +++ b/server/Makefile @@ -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 diff --git a/server/cmd/lunar-tear/grpc.go b/server/cmd/lunar-tear/grpc.go new file mode 100644 index 0000000..7cbf95b --- /dev/null +++ b/server/cmd/lunar-tear/grpc.go @@ -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 = "" + } + log.Printf(">>> %s", fullMethod) + err := status.Errorf(codes.Unimplemented, "unknown service or method %s", fullMethod) + log.Printf("<<< %s ERROR: %v", fullMethod, err) + return err +} diff --git a/server/cmd/lunar-tear/http.go b/server/cmd/lunar-tear/http.go new file mode 100644 index 0000000..c6e9ba6 --- /dev/null +++ b/server/cmd/lunar-tear/http.go @@ -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) + } +} diff --git a/server/cmd/lunar-tear/main.go b/server/cmd/lunar-tear/main.go new file mode 100644 index 0000000..4584c9d --- /dev/null +++ b/server/cmd/lunar-tear/main.go @@ -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, + ) +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..01f7c7e --- /dev/null +++ b/server/go.mod @@ -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 +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..10d34e9 --- /dev/null +++ b/server/go.sum @@ -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= diff --git a/server/internal/gacha/draw.go b/server/internal/gacha/draw.go new file mode 100644 index 0000000..19cbe0f --- /dev/null +++ b/server/internal/gacha/draw.go @@ -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, + } +} diff --git a/server/internal/gacha/handler.go b/server/internal/gacha/handler.go new file mode 100644 index 0000000..e92dd80 --- /dev/null +++ b/server/internal/gacha/handler.go @@ -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) +} diff --git a/server/internal/gametime/gametime.go b/server/internal/gametime/gametime.go new file mode 100644 index 0000000..8ce2b33 --- /dev/null +++ b/server/internal/gametime/gametime.go @@ -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() +} diff --git a/server/internal/gameutil/exp.go b/server/internal/gameutil/exp.go new file mode 100644 index 0000000..dc766f3 --- /dev/null +++ b/server/internal/gameutil/exp.go @@ -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 +} diff --git a/server/internal/masterdata/bighunt.go b/server/internal/masterdata/bighunt.go new file mode 100644 index 0000000..96100e2 --- /dev/null +++ b/server/internal/masterdata/bighunt.go @@ -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, + } +} diff --git a/server/internal/masterdata/cageornament.go b/server/internal/masterdata/cageornament.go new file mode 100644 index 0000000..b1fd85f --- /dev/null +++ b/server/internal/masterdata/cageornament.go @@ -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 +} diff --git a/server/internal/masterdata/character_rebirth.go b/server/internal/masterdata/character_rebirth.go new file mode 100644 index 0000000..821e9da --- /dev/null +++ b/server/internal/masterdata/character_rebirth.go @@ -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 +} diff --git a/server/internal/masterdata/characterboard.go b/server/internal/masterdata/characterboard.go new file mode 100644 index 0000000..9420a19 --- /dev/null +++ b/server/internal/masterdata/characterboard.go @@ -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 +} diff --git a/server/internal/masterdata/characterviewer.go b/server/internal/masterdata/characterviewer.go new file mode 100644 index 0000000..d086b38 --- /dev/null +++ b/server/internal/masterdata/characterviewer.go @@ -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 +} diff --git a/server/internal/masterdata/companion.go b/server/internal/masterdata/companion.go new file mode 100644 index 0000000..ba0ffcd --- /dev/null +++ b/server/internal/masterdata/companion.go @@ -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 +} diff --git a/server/internal/masterdata/conditions.go b/server/internal/masterdata/conditions.go new file mode 100644 index 0000000..aa30dd8 --- /dev/null +++ b/server/internal/masterdata/conditions.go @@ -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 +} diff --git a/server/internal/masterdata/config.go b/server/internal/masterdata/config.go new file mode 100644 index 0000000..ca97ea5 --- /dev/null +++ b/server/internal/masterdata/config.go @@ -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) +} diff --git a/server/internal/masterdata/costume.go b/server/internal/masterdata/costume.go new file mode 100644 index 0000000..e7aae18 --- /dev/null +++ b/server/internal/masterdata/costume.go @@ -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 +} diff --git a/server/internal/masterdata/dup_exchange.go b/server/internal/masterdata/dup_exchange.go new file mode 100644 index 0000000..9fa9c64 --- /dev/null +++ b/server/internal/masterdata/dup_exchange.go @@ -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 +} diff --git a/server/internal/masterdata/explore.go b/server/internal/masterdata/explore.go new file mode 100644 index 0000000..0935087 --- /dev/null +++ b/server/internal/masterdata/explore.go @@ -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 +} diff --git a/server/internal/masterdata/gacha.go b/server/internal/masterdata/gacha.go new file mode 100644 index 0000000..a42913f --- /dev/null +++ b/server/internal/masterdata/gacha.go @@ -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, + }, + } +} diff --git a/server/internal/masterdata/gacha_pool.go b/server/internal/masterdata/gacha_pool.go new file mode 100644 index 0000000..6ea0c49 --- /dev/null +++ b/server/internal/masterdata/gacha_pool.go @@ -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 +} diff --git a/server/internal/masterdata/gimmick.go b/server/internal/masterdata/gimmick.go new file mode 100644 index 0000000..eb8ca76 --- /dev/null +++ b/server/internal/masterdata/gimmick.go @@ -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 +} diff --git a/server/internal/masterdata/loginbonus.go b/server/internal/masterdata/loginbonus.go new file mode 100644 index 0000000..11122de --- /dev/null +++ b/server/internal/masterdata/loginbonus.go @@ -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 +} diff --git a/server/internal/masterdata/material.go b/server/internal/masterdata/material.go new file mode 100644 index 0000000..f1f13f0 --- /dev/null +++ b/server/internal/masterdata/material.go @@ -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 +} diff --git a/server/internal/masterdata/numericalfunc.go b/server/internal/masterdata/numericalfunc.go new file mode 100644 index 0000000..0c4e3b6 --- /dev/null +++ b/server/internal/masterdata/numericalfunc.go @@ -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 +} diff --git a/server/internal/masterdata/omikuji.go b/server/internal/masterdata/omikuji.go new file mode 100644 index 0000000..de2063d --- /dev/null +++ b/server/internal/masterdata/omikuji.go @@ -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 +} diff --git a/server/internal/masterdata/parts.go b/server/internal/masterdata/parts.go new file mode 100644 index 0000000..ee7ce09 --- /dev/null +++ b/server/internal/masterdata/parts.go @@ -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 +} diff --git a/server/internal/masterdata/quest.go b/server/internal/masterdata/quest.go new file mode 100644 index 0000000..4a65d69 --- /dev/null +++ b/server/internal/masterdata/quest.go @@ -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 +} diff --git a/server/internal/masterdata/shop.go b/server/internal/masterdata/shop.go new file mode 100644 index 0000000..bb29946 --- /dev/null +++ b/server/internal/masterdata/shop.go @@ -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 +} diff --git a/server/internal/masterdata/sidestory.go b/server/internal/masterdata/sidestory.go new file mode 100644 index 0000000..57c9210 --- /dev/null +++ b/server/internal/masterdata/sidestory.go @@ -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} +} diff --git a/server/internal/masterdata/weapon.go b/server/internal/masterdata/weapon.go new file mode 100644 index 0000000..925b47e --- /dev/null +++ b/server/internal/masterdata/weapon.go @@ -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 +} diff --git a/server/internal/model/characterboard.go b/server/internal/model/characterboard.go new file mode 100644 index 0000000..514066d --- /dev/null +++ b/server/internal/model/characterboard.go @@ -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 + } +} diff --git a/server/internal/model/deck.go b/server/internal/model/deck.go new file mode 100644 index 0000000..f8752a3 --- /dev/null +++ b/server/internal/model/deck.go @@ -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 +) diff --git a/server/internal/model/effect.go b/server/internal/model/effect.go new file mode 100644 index 0000000..a16418d --- /dev/null +++ b/server/internal/model/effect.go @@ -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 +) diff --git a/server/internal/model/evaluate.go b/server/internal/model/evaluate.go new file mode 100644 index 0000000..d4edad7 --- /dev/null +++ b/server/internal/model/evaluate.go @@ -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 +) diff --git a/server/internal/model/gacha.go b/server/internal/model/gacha.go new file mode 100644 index 0000000..d25d975 --- /dev/null +++ b/server/internal/model/gacha.go @@ -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 +) diff --git a/server/internal/model/material.go b/server/internal/model/material.go new file mode 100644 index 0000000..5fc8338 --- /dev/null +++ b/server/internal/model/material.go @@ -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 +) diff --git a/server/internal/model/numericalfunc.go b/server/internal/model/numericalfunc.go new file mode 100644 index 0000000..b718aed --- /dev/null +++ b/server/internal/model/numericalfunc.go @@ -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 +) diff --git a/server/internal/model/possession.go b/server/internal/model/possession.go new file mode 100644 index 0000000..be038e3 --- /dev/null +++ b/server/internal/model/possession.go @@ -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 +) diff --git a/server/internal/model/quest.go b/server/internal/model/quest.go new file mode 100644 index 0000000..81b8b01 --- /dev/null +++ b/server/internal/model/quest.go @@ -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 +) diff --git a/server/internal/model/rarity.go b/server/internal/model/rarity.go new file mode 100644 index 0000000..1d09b1d --- /dev/null +++ b/server/internal/model/rarity.go @@ -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 +) diff --git a/server/internal/model/shop.go b/server/internal/model/shop.go new file mode 100644 index 0000000..977791c --- /dev/null +++ b/server/internal/model/shop.go @@ -0,0 +1,9 @@ +package model + +const ( + ShopGroupTypeUnknown int32 = 0 + ShopGroupTypePremiumShop int32 = 1 + ShopGroupTypeItemShop int32 = 3 + ShopGroupTypeExchangeShop int32 = 4 + ShopGroupTypeRecoveryShop int32 = 5 +) diff --git a/server/internal/model/status.go b/server/internal/model/status.go new file mode 100644 index 0000000..0f67709 --- /dev/null +++ b/server/internal/model/status.go @@ -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 +) diff --git a/server/internal/model/tutorial.go b/server/internal/model/tutorial.go new file mode 100644 index 0000000..b8dbff9 --- /dev/null +++ b/server/internal/model/tutorial.go @@ -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 +) diff --git a/server/internal/questflow/bighunt_quest.go b/server/internal/questflow/bighunt_quest.go new file mode 100644 index 0000000..c37918d --- /dev/null +++ b/server/internal/questflow/bighunt_quest.go @@ -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 +} diff --git a/server/internal/questflow/event_quest.go b/server/internal/questflow/event_quest.go new file mode 100644 index 0000000..58437d1 --- /dev/null +++ b/server/internal/questflow/event_quest.go @@ -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) + } +} diff --git a/server/internal/questflow/extra_quest.go b/server/internal/questflow/extra_quest.go new file mode 100644 index 0000000..16cef74 --- /dev/null +++ b/server/internal/questflow/extra_quest.go @@ -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) + } +} diff --git a/server/internal/questflow/handler.go b/server/internal/questflow/handler.go new file mode 100644 index 0000000..c8f51cf --- /dev/null +++ b/server/internal/questflow/handler.go @@ -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, + } +} diff --git a/server/internal/questflow/quest.go b/server/internal/questflow/quest.go new file mode 100644 index 0000000..4c6626c --- /dev/null +++ b/server/internal/questflow/quest.go @@ -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 + } +} diff --git a/server/internal/questflow/rewards.go b/server/internal/questflow/rewards.go new file mode 100644 index 0000000..cbf6f28 --- /dev/null +++ b/server/internal/questflow/rewards.go @@ -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 + } + } + } + } +} diff --git a/server/internal/questflow/scene.go b/server/internal/questflow/scene.go new file mode 100644 index 0000000..b2d25a5 --- /dev/null +++ b/server/internal/questflow/scene.go @@ -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 + } +} diff --git a/server/internal/service/asset_resolver.go b/server/internal/service/asset_resolver.go new file mode 100644 index 0000000..f9d56e7 --- /dev/null +++ b/server/internal/service/asset_resolver.go @@ -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) +} diff --git a/server/internal/service/banner.go b/server/internal/service/banner.go new file mode 100644 index 0000000..39b3f8f --- /dev/null +++ b/server/internal/service/banner.go @@ -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 +} diff --git a/server/internal/service/battle.go b/server/internal/service/battle.go new file mode 100644 index 0000000..1fa3832 --- /dev/null +++ b/server/internal/service/battle.go @@ -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 +} diff --git a/server/internal/service/cageornament.go b/server/internal/service/cageornament.go new file mode 100644 index 0000000..8e7e924 --- /dev/null +++ b/server/internal/service/cageornament.go @@ -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 +} diff --git a/server/internal/service/character.go b/server/internal/service/character.go new file mode 100644 index 0000000..168118f --- /dev/null +++ b/server/internal/service/character.go @@ -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 +} diff --git a/server/internal/service/characterboard.go b/server/internal/service/characterboard.go new file mode 100644 index 0000000..42f8978 --- /dev/null +++ b/server/internal/service/characterboard.go @@ -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 +} diff --git a/server/internal/service/characterviewer.go b/server/internal/service/characterviewer.go new file mode 100644 index 0000000..535397c --- /dev/null +++ b/server/internal/service/characterviewer.go @@ -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 +} diff --git a/server/internal/service/companion.go b/server/internal/service/companion.go new file mode 100644 index 0000000..fbc6bce --- /dev/null +++ b/server/internal/service/companion.go @@ -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 +} diff --git a/server/internal/service/config.go b/server/internal/service/config.go new file mode 100644 index 0000000..12c725d --- /dev/null +++ b/server/internal/service/config.go @@ -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 +} diff --git a/server/internal/service/contentsstory.go b/server/internal/service/contentsstory.go new file mode 100644 index 0000000..5c7b084 --- /dev/null +++ b/server/internal/service/contentsstory.go @@ -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 +} diff --git a/server/internal/service/costume.go b/server/internal/service/costume.go new file mode 100644 index 0000000..39f374c --- /dev/null +++ b/server/internal/service/costume.go @@ -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 +} diff --git a/server/internal/service/data.go b/server/internal/service/data.go new file mode 100644 index 0000000..b67c600 --- /dev/null +++ b/server/internal/service/data.go @@ -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", + } +} diff --git a/server/internal/service/deck.go b/server/internal/service/deck.go new file mode 100644 index 0000000..5e1d65d --- /dev/null +++ b/server/internal/service/deck.go @@ -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 +} diff --git a/server/internal/service/dokan.go b/server/internal/service/dokan.go new file mode 100644 index 0000000..a24d9a9 --- /dev/null +++ b/server/internal/service/dokan.go @@ -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 +} diff --git a/server/internal/service/explore.go b/server/internal/service/explore.go new file mode 100644 index 0000000..32ca95e --- /dev/null +++ b/server/internal/service/explore.go @@ -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 +} diff --git a/server/internal/service/friend.go b/server/internal/service/friend.go new file mode 100644 index 0000000..15fbcba --- /dev/null +++ b/server/internal/service/friend.go @@ -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 +} diff --git a/server/internal/service/gacha.go b/server/internal/service/gacha.go new file mode 100644 index 0000000..38a0b5a --- /dev/null +++ b/server/internal/service/gacha.go @@ -0,0 +1,647 @@ +package service + +import ( + "context" + "fmt" + "log" + "time" + + pb "lunar-tear/server/gen/proto" + "lunar-tear/server/internal/gacha" + "lunar-tear/server/internal/gametime" + "lunar-tear/server/internal/model" + "lunar-tear/server/internal/store" + "lunar-tear/server/internal/userdata" + + emptypb "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var gachaDiffTables = []string{ + "IUserGem", + "IUserCostume", + "IUserWeapon", + "IUserConsumableItem", + "IUserCostumeActiveSkill", + "IUserWeaponNote", + "IUserWeaponSkill", + "IUserWeaponAbility", + "IUserWeaponStory", + "IUserCharacter", + "IUserMaterial", +} + +type GachaServiceServer struct { + pb.UnimplementedGachaServiceServer + users store.UserRepository + sessions store.SessionRepository + gacha store.GachaRepository + handler *gacha.GachaHandler +} + +func NewGachaServiceServer( + users store.UserRepository, + sessions store.SessionRepository, + gachaRepo store.GachaRepository, + handler *gacha.GachaHandler, +) *GachaServiceServer { + return &GachaServiceServer{ + users: users, + sessions: sessions, + gacha: gachaRepo, + handler: handler, + } +} + +func (s *GachaServiceServer) GetGachaList(ctx context.Context, req *pb.GetGachaListRequest) (*pb.GetGachaListResponse, error) { + log.Printf("[GachaService] GetGachaList: labels=%v", req.GachaLabelType) + + catalog, _ := s.gacha.SnapshotCatalog() + userId := currentUserId(ctx, s.users, s.sessions) + nowMillis := gametime.NowMillis() + + user, err := s.users.UpdateUser(userId, func(user *store.UserState) { + user.EnsureMaps() + s.autoConvertExpiredMedals(user, catalog, nowMillis) + }) + if err != nil { + return nil, fmt.Errorf("update user: %w", err) + } + + gachaList := make([]*pb.Gacha, 0, len(catalog)) + for _, entry := range catalog { + if !matchesGachaLabel(req.GachaLabelType, entry.GachaLabelType) { + continue + } + if entry.GachaLabelType == model.GachaLabelPortalCage || entry.GachaLabelType == model.GachaLabelRecycle { + continue + } + bs := user.Gacha.BannerStates[entry.GachaId] + gachaList = append(gachaList, toProtoGacha(entry, &bs)) + } + + return &pb.GetGachaListResponse{ + Gacha: gachaList, + ConvertedGachaMedal: toProtoConvertedGachaMedal(user.Gacha.ConvertedGachaMedal), + DiffUserData: userdata.EmptyDiff(), + }, nil +} + +func (s *GachaServiceServer) autoConvertExpiredMedals(user *store.UserState, catalog []store.GachaCatalogEntry, nowMillis int64) { + for _, entry := range catalog { + if entry.GachaMedalId == 0 || entry.EndDatetime == 0 { + continue + } + if nowMillis < entry.EndDatetime { + continue + } + bs, exists := user.Gacha.BannerStates[entry.GachaId] + if !exists || bs.MedalCount <= 0 { + continue + } + + medalInfo, ok := s.handler.MedalInfo[entry.GachaId] + if !ok { + continue + } + + conversionRate := medalInfo.ConversionRate + if conversionRate <= 0 { + conversionRate = 1 + } + bookmarkCount := bs.MedalCount * conversionRate + + user.ConsumableItems[medalInfo.ConsumableItemId] += bookmarkCount + + user.Gacha.ConvertedGachaMedal.ConvertedMedalPossession = append( + user.Gacha.ConvertedGachaMedal.ConvertedMedalPossession, + store.ConsumableItemState{ + ConsumableItemId: medalInfo.ConsumableItemId, + Count: bookmarkCount, + }, + ) + + originalCount := bs.MedalCount + bs.MedalCount = 0 + user.Gacha.BannerStates[entry.GachaId] = bs + + log.Printf("[GachaService] auto-converted %d medals for gacha %d -> %d bookmarks (item %d)", + originalCount, entry.GachaId, bookmarkCount, medalInfo.ConsumableItemId) + } +} + +func (s *GachaServiceServer) GetGacha(ctx context.Context, req *pb.GetGachaRequest) (*pb.GetGachaResponse, error) { + log.Printf("[GachaService] GetGacha: ids=%v", req.GachaId) + + catalog, _ := s.gacha.SnapshotCatalog() + + userId := currentUserId(ctx, s.users, s.sessions) + user, err := s.users.SnapshotUser(userId) + if err != nil { + return nil, fmt.Errorf("snapshot user: %w", err) + } + + byId := make(map[int32]*pb.Gacha, len(req.GachaId)) + for _, wantedId := range req.GachaId { + for _, entry := range catalog { + if entry.GachaId == wantedId { + bs := user.Gacha.BannerStates[entry.GachaId] + byId[wantedId] = toProtoGacha(entry, &bs) + break + } + } + } + + return &pb.GetGachaResponse{ + Gacha: byId, + DiffUserData: userdata.EmptyDiff(), + }, nil +} + +func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb.DrawResponse, error) { + log.Printf("[GachaService] Draw: gachaId=%d phaseId=%d execCount=%d", req.GachaId, req.GachaPricePhaseId, req.ExecCount) + + catalog, _ := s.gacha.SnapshotCatalog() + entry := findCatalogEntry(catalog, req.GachaId) + if entry == nil { + return nil, fmt.Errorf("gacha %d not found", req.GachaId) + } + + userId := currentUserId(ctx, s.users, s.sessions) + execCount := req.ExecCount + if execCount <= 0 { + execCount = 1 + } + + var drawResult *gacha.DrawResult + updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) { + var drawErr error + drawResult, drawErr = s.handler.HandleDraw(user, *entry, req.GachaPricePhaseId, execCount) + if drawErr != nil { + log.Printf("[GachaService] Draw error: %v", drawErr) + drawResult = &gacha.DrawResult{} + } + }) + if err != nil { + return nil, fmt.Errorf("update user: %w", err) + } + + for i, item := range drawResult.Items { + if bonus, ok := drawResult.BonusItems[i]; ok { + log.Printf("[GachaService] drawn[%d]: type=%d id=%d rarity=%d + bonus type=%d id=%d rarity=%d", + i, item.PossessionType, item.PossessionId, item.RarityType, + bonus.PossessionType, bonus.PossessionId, bonus.RarityType) + } else { + log.Printf("[GachaService] drawn[%d]: type=%d id=%d rarity=%d", + i, item.PossessionType, item.PossessionId, item.RarityType) + } + } + + gachaResults := make([]*pb.DrawGachaOddsItem, 0, len(drawResult.Items)) + dupMap := make(map[int]gacha.DuplicateInfo) + for _, d := range drawResult.DuplicateInfos { + dupMap[d.Index] = d + } + bonusDupMap := make(map[int]gacha.DuplicateInfo) + for _, d := range drawResult.BonusDuplicateInfos { + bonusDupMap[d.Index] = d + } + + costumePT := int32(model.PossessionTypeCostume) + weaponPT := int32(model.PossessionTypeWeapon) + isMaterialDraw := model.IsMaterialBanner(entry.GachaLabelType) + + ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes)) + for _, c := range updatedUser.Costumes { + ownedCostumes[c.CostumeId] = true + } + ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons)) + for _, w := range updatedUser.Weapons { + ownedWeapons[w.WeaponId] = true + } + + for i, item := range drawResult.Items { + isNew := !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser) + + var oddsItem *pb.DrawGachaOddsItem + + if isMaterialDraw { + oddsItem = &pb.DrawGachaOddsItem{ + GachaItem: &pb.GachaItem{ + PossessionType: item.PossessionType, + PossessionId: item.PossessionId, + Count: 1, + IsNew: isNew, + }, + GachaItemBonus: &pb.GachaItem{}, + } + } else if bonus, hasBonusWeapon := drawResult.BonusItems[i]; hasBonusWeapon { + oddsItem = &pb.DrawGachaOddsItem{ + GachaItem: &pb.GachaItem{ + PossessionType: costumePT, + PossessionId: item.PossessionId, + Count: 1, + IsNew: isNew, + }, + GachaItemBonus: &pb.GachaItem{ + PossessionType: weaponPT, + PossessionId: bonus.PossessionId, + Count: 1, + IsNew: !ownedWeapons[bonus.PossessionId], + }, + } + } else { + oddsItem = &pb.DrawGachaOddsItem{ + GachaItem: &pb.GachaItem{ + PossessionType: item.PossessionType, + PossessionId: item.PossessionId, + Count: 1, + IsNew: isNew, + }, + GachaItemBonus: &pb.GachaItem{}, + } + } + + if drawResult.MedalBonus > 0 && entry.MedalConsumableItemId != 0 { + oddsItem.MedalBonus = &pb.GachaBonus{ + PossessionType: int32(model.PossessionTypeConsumableItem), + PossessionId: entry.MedalConsumableItemId, + Count: 0, + } + } + + if dup, ok := dupMap[i]; ok { + applyDuplicationBonus(oddsItem, dup) + } + if bdup, ok := bonusDupMap[i]; ok { + applyDuplicationBonus(oddsItem, bdup) + } + + gachaResults = append(gachaResults, oddsItem) + } + + var bonuses []*pb.GachaBonus + for _, b := range drawResult.Bonuses { + bonuses = append(bonuses, &pb.GachaBonus{ + PossessionType: b.PossessionType, + PossessionId: b.PossessionId, + Count: b.Count, + }) + } + + bs := updatedUser.Gacha.BannerStates[entry.GachaId] + nextGacha := toProtoGacha(*entry, &bs) + + diff := userdata.BuildDiffFromTables(userdata.SelectTables( + userdata.FullClientTableMap(updatedUser), + gachaDiffTables, + )) + + return &pb.DrawResponse{ + NextGacha: nextGacha, + GachaResult: gachaResults, + GachaBonus: bonuses, + MenuGachaBadgeInfo: []*pb.MenuGachaBadgeInfo{}, + DiffUserData: diff, + }, nil +} + +func (s *GachaServiceServer) ResetBoxGacha(ctx context.Context, req *pb.ResetBoxGachaRequest) (*pb.ResetBoxGachaResponse, error) { + log.Printf("[GachaService] ResetBoxGacha: gachaId=%d", req.GachaId) + + catalog, _ := s.gacha.SnapshotCatalog() + entry := findCatalogEntry(catalog, req.GachaId) + if entry == nil { + return nil, fmt.Errorf("gacha %d not found", req.GachaId) + } + + userId := currentUserId(ctx, s.users, s.sessions) + updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) { + if resetErr := s.handler.HandleResetBox(user, *entry); resetErr != nil { + log.Printf("[GachaService] ResetBoxGacha error: %v", resetErr) + } + }) + if err != nil { + return nil, fmt.Errorf("update user: %w", err) + } + + bs := updatedUser.Gacha.BannerStates[entry.GachaId] + + return &pb.ResetBoxGachaResponse{ + Gacha: toProtoGacha(*entry, &bs), + DiffUserData: userdata.EmptyDiff(), + }, nil +} + +func (s *GachaServiceServer) GetRewardGacha(ctx context.Context, req *emptypb.Empty) (*pb.GetRewardGachaResponse, error) { + log.Printf("[GachaService] GetRewardGacha") + userId := currentUserId(ctx, s.users, s.sessions) + user, err := s.users.SnapshotUser(userId) + if err != nil { + return nil, fmt.Errorf("snapshot user: %w", err) + } + + maxCount := s.handler.Config.RewardGachaDailyMaxCount + if maxCount <= 0 { + maxCount = model.DefaultDailyDrawLimit + } + + todayStart := gametime.StartOfDayMillis() + drawCount := user.Gacha.TodaysCurrentDrawCount + if user.Gacha.LastRewardDrawDate < todayStart { + drawCount = 0 + } + + return &pb.GetRewardGachaResponse{ + Available: drawCount < maxCount, + TodaysCurrentDrawCount: drawCount, + DailyMaxCount: maxCount, + DiffUserData: userdata.EmptyDiff(), + }, nil +} + +func (s *GachaServiceServer) RewardDraw(ctx context.Context, req *pb.RewardDrawRequest) (*pb.RewardDrawResponse, error) { + log.Printf("[GachaService] RewardDraw: placement=%q reward=%q amount=%q", req.PlacementName, req.RewardName, req.RewardAmount) + + userId := currentUserId(ctx, s.users, s.sessions) + + var items []gacha.DrawnItem + updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) { + var drawErr error + items, drawErr = s.handler.HandleRewardDraw(user, 1) + if drawErr != nil { + log.Printf("[GachaService] RewardDraw error: %v", drawErr) + } + }) + if err != nil { + return nil, fmt.Errorf("update user: %w", err) + } + + ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes)) + for _, c := range updatedUser.Costumes { + ownedCostumes[c.CostumeId] = true + } + ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons)) + for _, w := range updatedUser.Weapons { + ownedWeapons[w.WeaponId] = true + } + + results := make([]*pb.RewardGachaItem, 0, len(items)) + for _, item := range items { + results = append(results, &pb.RewardGachaItem{ + PossessionType: item.PossessionType, + PossessionId: item.PossessionId, + Count: 1, + IsNew: !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser), + }) + } + + tables := userdata.FullClientTableMap(updatedUser) + diff := userdata.BuildDiffFromTables(tables) + + return &pb.RewardDrawResponse{ + RewardGachaResult: results, + DiffUserData: diff, + }, nil +} + +func findCatalogEntry(catalog []store.GachaCatalogEntry, gachaId int32) *store.GachaCatalogEntry { + for i := range catalog { + if catalog[i].GachaId == gachaId { + return &catalog[i] + } + } + return nil +} + +func matchesGachaLabel(labels []int32, label int32) bool { + if len(labels) == 0 { + return true + } + for _, candidate := range labels { + if candidate == label { + return true + } + } + return false +} + +func toProtoGacha(entry store.GachaCatalogEntry, bs *store.GachaBannerState) *pb.Gacha { + g := &pb.Gacha{ + GachaId: entry.GachaId, + GachaLabelType: entry.GachaLabelType, + GachaModeType: entry.GachaModeType, + GachaAutoResetType: entry.GachaAutoResetType, + GachaAutoResetPeriod: entry.GachaAutoResetPeriod, + NextAutoResetDatetime: safeTimestamp(entry.NextAutoResetDatetime), + GachaUnlockCondition: []*pb.GachaUnlockCondition{{GachaUnlockConditionType: model.GachaUnlockNone, ConditionValue: 0}}, + IsUserGachaUnlock: entry.IsUserGachaUnlock, + StartDatetime: safeTimestamp(entry.StartDatetime), + EndDatetime: safeTimestamp(entry.EndDatetime), + RelatedMainQuestChapterId: entry.RelatedMainQuestChapterId, + RelatedEventQuestChapterId: entry.RelatedEventQuestChapterId, + PromotionMovieAssetId: entry.PromotionMovieAssetId, + GachaMedalId: entry.GachaMedalId, + GachaDecorationType: entry.GachaDecorationType, + SortOrder: entry.SortOrder, + IsInactive: entry.IsInactive, + InformationId: entry.InformationId, + } + + g.GachaPricePhase = buildProtoPricePhases(entry, bs) + + promotionItems := buildProtoPromotionItems(entry) + + switch entry.GachaModeType { + case model.GachaModeBox: + boxNumber := int32(1) + if bs != nil && bs.BoxNumber > 0 { + boxNumber = bs.BoxNumber + } + phaseId := int32(0) + if len(entry.PricePhases) > 0 { + phaseId = entry.PricePhases[0].PhaseId + } + g.GachaMode = &pb.Gacha_GachaModeBoxComposition{ + GachaModeBoxComposition: &pb.GachaModeBoxComposition{ + GachaBoxGroupId: entry.GroupId, + BoxNumber: boxNumber, + CurrentBoxNumber: boxNumber, + NaviCharacterCommentAssetName: "production", + GachaAssetName: entry.BannerAssetName, + GachaPricePhaseId: phaseId, + PromotionGachaOddsItem: promotionItems, + GachaDescriptionTextId: entry.DescriptionTextId, + }, + } + case model.GachaModeStepup: + stepNumber := int32(1) + loopCount := int32(0) + if bs != nil { + if bs.StepNumber > 0 { + stepNumber = bs.StepNumber + } + loopCount = bs.LoopCount + } + g.GachaMode = &pb.Gacha_GachaModeStepupComposition{ + GachaModeStepupComposition: &pb.GachaModeStepupComposition{ + GachaStepGroupId: entry.GroupId, + StepNumber: 1, + CurrentStepNumber: stepNumber, + NaviCharacterCommentAssetName: "production", + GachaAssetName: entry.BannerAssetName, + PromotionGachaOddsItem: promotionItems, + CurrentLoopCount: loopCount, + }, + } + default: + g.GachaMode = &pb.Gacha_GachaModeBasic{ + GachaModeBasic: &pb.GachaModeBasic{ + NaviCharacterCommentAssetName: "production", + GachaAssetName: entry.BannerAssetName, + PromotionGachaOddsItem: promotionItems, + }, + } + } + + return g +} + +func buildProtoPricePhases(entry store.GachaCatalogEntry, bs *store.GachaBannerState) []*pb.GachaPricePhase { + phases := make([]*pb.GachaPricePhase, 0, len(entry.PricePhases)) + + for _, p := range entry.PricePhases { + isEnabled := true + if entry.GachaModeType == model.GachaModeStepup && bs != nil { + currentStep := bs.StepNumber + if currentStep <= 0 { + currentStep = 1 + } + isEnabled = p.StepNumber == currentStep + } + + var bonuses []*pb.GachaBonus + for _, b := range p.Bonuses { + bonuses = append(bonuses, &pb.GachaBonus{ + PossessionType: b.PossessionType, + PossessionId: b.PossessionId, + Count: b.Count, + }) + } + + limitExec := p.LimitExecCount + if limitExec <= 0 { + limitExec = 999 + } + + phases = append(phases, &pb.GachaPricePhase{ + GachaPricePhaseId: p.PhaseId, + IsEnabled: isEnabled, + EndDatetime: safeTimestamp(entry.EndDatetime), + PriceType: p.PriceType, + PriceId: p.PriceId, + Price: p.Price, + RegularPrice: p.RegularPrice, + DrawCount: p.DrawCount, + LimitExecCount: limitExec, + EachMaxExecCount: p.DrawCount, + GachaBonus: bonuses, + GachaOddsFixedRarity: &pb.GachaOddsFixedRarity{ + FixedRarityTypeLowerLimit: p.FixedRarityMin, + FixedCount: p.FixedCount, + }, + GachaBadgeType: model.GachaBadgeTypeNone, + }) + } + + return phases +} + +func buildProtoPromotionItems(entry store.GachaCatalogEntry) []*pb.GachaOddsItem { + if len(entry.PromotionItems) == 0 { + return nil + } + isMaterial := model.IsMaterialBanner(entry.GachaLabelType) + + items := make([]*pb.GachaOddsItem, 0, len(entry.PromotionItems)) + for i, pi := range entry.PromotionItems { + bonus := &pb.GachaItem{} + if !isMaterial && pi.BonusPossessionType != 0 { + bonus = &pb.GachaItem{ + PossessionType: pi.BonusPossessionType, + PossessionId: pi.BonusPossessionId, + Count: 1, + } + } + items = append(items, &pb.GachaOddsItem{ + GachaItem: &pb.GachaItem{ + PossessionType: pi.PossessionType, + PossessionId: pi.PossessionId, + Count: 1, + PromotionOrder: int32(i + 1), + }, + GachaItemBonus: bonus, + MaxDrawableCount: 999, + IsTarget: pi.IsTarget, + }) + } + return items +} + +func toProtoConvertedGachaMedal(state store.ConvertedGachaMedalState) *pb.ConvertedGachaMedal { + items := make([]*pb.ConsumableItemPossession, 0, len(state.ConvertedMedalPossession)) + for _, item := range state.ConvertedMedalPossession { + items = append(items, &pb.ConsumableItemPossession{ + ConsumableItemId: item.ConsumableItemId, + Count: item.Count, + }) + } + + obtain := &pb.ConsumableItemPossession{ + ConsumableItemId: 0, + Count: 0, + } + if state.ObtainPossession != nil { + obtain.ConsumableItemId = state.ObtainPossession.ConsumableItemId + obtain.Count = state.ObtainPossession.Count + } + + return &pb.ConvertedGachaMedal{ + ConvertedMedalPossession: items, + ObtainPossession: obtain, + } +} + +func safeTimestamp(unixMillis int64) *timestamppb.Timestamp { + if unixMillis == 0 { + return ×tamppb.Timestamp{Seconds: 0} + } + return timestamppb.New(time.UnixMilli(unixMillis)) +} + +func applyDuplicationBonus(oddsItem *pb.DrawGachaOddsItem, dup gacha.DuplicateInfo) { + if oddsItem.DuplicationBonusGrade == 0 { + oddsItem.DuplicationBonusGrade = dup.Grade + } + for _, b := range dup.Bonuses { + oddsItem.DuplicationBonus = append(oddsItem.DuplicationBonus, &pb.GachaBonus{ + PossessionType: b.PossessionType, + PossessionId: b.PossessionId, + Count: b.Count, + }) + } +} + +func isOwnedByType(item gacha.DrawnItem, costumes, weapons map[int32]bool, user store.UserState) bool { + switch item.PossessionType { + case int32(model.PossessionTypeCostume): + return costumes[item.PossessionId] + case int32(model.PossessionTypeWeapon): + return weapons[item.PossessionId] + case int32(model.PossessionTypeMaterial): + return user.Materials[item.PossessionId] > 0 + case int32(model.PossessionTypeWeaponEnhanced): + return user.ConsumableItems[item.PossessionId] > 0 + } + return false +} diff --git a/server/internal/service/gameplay.go b/server/internal/service/gameplay.go new file mode 100644 index 0000000..a76c92e --- /dev/null +++ b/server/internal/service/gameplay.go @@ -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 +} diff --git a/server/internal/service/gift.go b/server/internal/service/gift.go new file mode 100644 index 0000000..47afe01 --- /dev/null +++ b/server/internal/service/gift.go @@ -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) +} diff --git a/server/internal/service/gimmick.go b/server/internal/service/gimmick.go new file mode 100644 index 0000000..3dc0d4a --- /dev/null +++ b/server/internal/service/gimmick.go @@ -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 +} diff --git a/server/internal/service/listbin.go b/server/internal/service/listbin.go new file mode 100644 index 0000000..e1113e0 --- /dev/null +++ b/server/internal/service/listbin.go @@ -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 +} diff --git a/server/internal/service/loginbonus.go b/server/internal/service/loginbonus.go new file mode 100644 index 0000000..3c6f597 --- /dev/null +++ b/server/internal/service/loginbonus.go @@ -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 +} diff --git a/server/internal/service/material.go b/server/internal/service/material.go new file mode 100644 index 0000000..b111d64 --- /dev/null +++ b/server/internal/service/material.go @@ -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 +} diff --git a/server/internal/service/mission.go b/server/internal/service/mission.go new file mode 100644 index 0000000..ed91238 --- /dev/null +++ b/server/internal/service/mission.go @@ -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 +} diff --git a/server/internal/service/movie.go b/server/internal/service/movie.go new file mode 100644 index 0000000..dd544f9 --- /dev/null +++ b/server/internal/service/movie.go @@ -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 +} diff --git a/server/internal/service/navicutin.go b/server/internal/service/navicutin.go new file mode 100644 index 0000000..44dcf14 --- /dev/null +++ b/server/internal/service/navicutin.go @@ -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 +} diff --git a/server/internal/service/notification.go b/server/internal/service/notification.go new file mode 100644 index 0000000..0cbbb24 --- /dev/null +++ b/server/internal/service/notification.go @@ -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 +} diff --git a/server/internal/service/octo.go b/server/internal/service/octo.go new file mode 100644 index 0000000..47ca60a --- /dev/null +++ b/server/internal/service/octo.go @@ -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 = ` + + + + +Lunar Tear + + + +

LUNAR TEAR

+
Private Preservation Server
+
+

A community effort to keep NieR Re[in]carnation playable after official service ended.

+

This server is not affiliated with or endorsed by SQUARE ENIX or Applibot.

+
+

© SQUARE ENIX / Applibot — All game assets belong to their respective owners.

+ +` + +// 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 "" + title + "

" + title + + "

Language: " + language + "

Version: " + version + "

" +} + +// 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(``)) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(200) + w.Write([]byte(``)) +} diff --git a/server/internal/service/omikuji.go b/server/internal/service/omikuji.go new file mode 100644 index 0000000..b501434 --- /dev/null +++ b/server/internal/service/omikuji.go @@ -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 +} diff --git a/server/internal/service/parts.go b/server/internal/service/parts.go new file mode 100644 index 0000000..3fc4b2d --- /dev/null +++ b/server/internal/service/parts.go @@ -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 +} diff --git a/server/internal/service/portalcage.go b/server/internal/service/portalcage.go new file mode 100644 index 0000000..c1af161 --- /dev/null +++ b/server/internal/service/portalcage.go @@ -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 +} diff --git a/server/internal/service/quest_bighunt.go b/server/internal/service/quest_bighunt.go new file mode 100644 index 0000000..2fd5c01 --- /dev/null +++ b/server/internal/service/quest_bighunt.go @@ -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 +} diff --git a/server/internal/service/quest_event.go b/server/internal/service/quest_event.go new file mode 100644 index 0000000..14cfe40 --- /dev/null +++ b/server/internal/service/quest_event.go @@ -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 +} diff --git a/server/internal/service/quest_extra.go b/server/internal/service/quest_extra.go new file mode 100644 index 0000000..02b6bee --- /dev/null +++ b/server/internal/service/quest_extra.go @@ -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 +} diff --git a/server/internal/service/quest_main.go b/server/internal/service/quest_main.go new file mode 100644 index 0000000..1d02501 --- /dev/null +++ b/server/internal/service/quest_main.go @@ -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 +} diff --git a/server/internal/service/quest_sidestory.go b/server/internal/service/quest_sidestory.go new file mode 100644 index 0000000..b33887d --- /dev/null +++ b/server/internal/service/quest_sidestory.go @@ -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 +} diff --git a/server/internal/service/reward.go b/server/internal/service/reward.go new file mode 100644 index 0000000..0ffe96e --- /dev/null +++ b/server/internal/service/reward.go @@ -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 +} diff --git a/server/internal/service/shop.go b/server/internal/service/shop.go new file mode 100644 index 0000000..cfdac7b --- /dev/null +++ b/server/internal/service/shop.go @@ -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 + } +} diff --git a/server/internal/service/state.go b/server/internal/service/state.go new file mode 100644 index 0000000..290b31a --- /dev/null +++ b/server/internal/service/state.go @@ -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 +} diff --git a/server/internal/service/tutorial.go b/server/internal/service/tutorial.go new file mode 100644 index 0000000..05de533 --- /dev/null +++ b/server/internal/service/tutorial.go @@ -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 +} diff --git a/server/internal/service/user.go b/server/internal/service/user.go new file mode 100644 index 0000000..a980331 --- /dev/null +++ b/server/internal/service/user.go @@ -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 +} diff --git a/server/internal/service/weapon.go b/server/internal/service/weapon.go new file mode 100644 index 0000000..6844a56 --- /dev/null +++ b/server/internal/service/weapon.go @@ -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) + } + } +} diff --git a/server/internal/store/helpers.go b/server/internal/store/helpers.go new file mode 100644 index 0000000..efb62e0 --- /dev/null +++ b/server/internal/store/helpers.go @@ -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 +} diff --git a/server/internal/store/mapkey.go b/server/internal/store/mapkey.go new file mode 100644 index 0000000..9a1bebb --- /dev/null +++ b/server/internal/store/mapkey.go @@ -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 +} diff --git a/server/internal/store/memory/clone.go b/server/internal/store/memory/clone.go new file mode 100644 index 0000000..a72880f --- /dev/null +++ b/server/internal/store/memory/clone.go @@ -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 +} diff --git a/server/internal/store/memory/memory.go b/server/internal/store/memory/memory.go new file mode 100644 index 0000000..ad3c6fb --- /dev/null +++ b/server/internal/store/memory/memory.go @@ -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 +} diff --git a/server/internal/store/memory/seed.go b/server/internal/store/memory/seed.go new file mode 100644 index 0000000..9d76d76 --- /dev/null +++ b/server/internal/store/memory/seed.go @@ -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 + } + } +} diff --git a/server/internal/store/memory/snapshot.go b/server/internal/store/memory/snapshot.go new file mode 100644 index 0000000..b31eac5 --- /dev/null +++ b/server/internal/store/memory/snapshot.go @@ -0,0 +1,47 @@ +package memory + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + + "lunar-tear/server/internal/store" +) + +func snapshotPath(dir string, sceneId int32) string { + return filepath.Join(dir, fmt.Sprintf("scene_%d.json", sceneId)) +} + +func saveSnapshot(user *store.UserState, dir string) { + sceneId := user.MainQuest.CurrentQuestSceneId + if sceneId == 0 { + return + } + data, err := json.MarshalIndent(user, "", " ") + if err != nil { + log.Printf("[snapshot] marshal error for scene=%d: %v", sceneId, err) + return + } + path := snapshotPath(dir, sceneId) + if err := os.WriteFile(path, data, 0644); err != nil { + log.Printf("[snapshot] write error for scene=%d: %v", sceneId, err) + return + } + log.Printf("[snapshot] saved scene=%d (%d bytes)", sceneId, len(data)) +} + +func loadSnapshot(dir string, sceneId int32) (*store.UserState, error) { + path := snapshotPath(dir, sceneId) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read snapshot scene=%d: %w", sceneId, err) + } + var user store.UserState + if err := json.Unmarshal(data, &user); err != nil { + return nil, fmt.Errorf("unmarshal snapshot scene=%d: %w", sceneId, err) + } + user.EnsureMaps() + return &user, nil +} diff --git a/server/internal/store/stamina.go b/server/internal/store/stamina.go new file mode 100644 index 0000000..549e82d --- /dev/null +++ b/server/internal/store/stamina.go @@ -0,0 +1,41 @@ +package store + +import "log" + +const StaminaRecoveryDivisor int64 = 180 + +func SettleStamina(user *UserState, maxStaminaMillis int32, nowMillis int64) { + stored := int64(user.Status.StaminaMilliValue) + maxMilli := int64(maxStaminaMillis) + if stored >= maxMilli { + return + } + elapsed := nowMillis - user.Status.StaminaUpdateDatetime + if elapsed <= 0 { + return + } + regen := elapsed / StaminaRecoveryDivisor + settled := min(stored+regen, maxMilli) + user.Status.StaminaMilliValue = int32(settled) + user.Status.StaminaUpdateDatetime = nowMillis +} + +func ConsumeStamina(user *UserState, costUnits int32, maxStaminaMillis int32, nowMillis int64) { + SettleStamina(user, maxStaminaMillis, nowMillis) + user.Status.StaminaMilliValue = max(user.Status.StaminaMilliValue-costUnits*1000, 0) + user.Status.StaminaUpdateDatetime = nowMillis + log.Printf("[ConsumeStamina] cost=%d -> remaining=%d", costUnits, user.Status.StaminaMilliValue) +} + +func RecoverStamina(user *UserState, millis int32, maxStaminaMillis int32, nowMillis int64) { + SettleStamina(user, maxStaminaMillis, nowMillis) + user.Status.StaminaMilliValue += millis + user.Status.StaminaUpdateDatetime = nowMillis + log.Printf("[RecoverStamina] +%d -> total=%d", millis, user.Status.StaminaMilliValue) +} + +func ReplenishStamina(user *UserState, maxStaminaMillis int32, nowMillis int64) { + user.Status.StaminaMilliValue = maxStaminaMillis + user.Status.StaminaUpdateDatetime = nowMillis + log.Printf("[ReplenishStamina] set to %d", maxStaminaMillis) +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go new file mode 100644 index 0000000..edd2ad4 --- /dev/null +++ b/server/internal/store/store.go @@ -0,0 +1,27 @@ +package store + +import ( + "errors" + "time" +) + +var ErrNotFound = errors.New("store: not found") + +type Clock func() time.Time + +type UserRepository interface { + EnsureUser(uuid string) (UserState, error) + SnapshotUser(userId int64) (UserState, error) + UpdateUser(userId int64, mutate func(*UserState)) (UserState, error) + DefaultUserId() (int64, error) +} + +type SessionRepository interface { + CreateSession(uuid string, ttl time.Duration) (UserState, SessionState, error) + ResolveUserId(sessionKey string) (int64, error) +} + +type GachaRepository interface { + SnapshotCatalog() ([]GachaCatalogEntry, error) + ReplaceCatalog(entries []GachaCatalogEntry) error +} diff --git a/server/internal/store/types.go b/server/internal/store/types.go new file mode 100644 index 0000000..2e82928 --- /dev/null +++ b/server/internal/store/types.go @@ -0,0 +1,1063 @@ +package store + +import ( + "fmt" + "strconv" + "strings" + "time" + + "lunar-tear/server/internal/model" +) + +type SessionState struct { + SessionKey string + UserId int64 + Uuid string + ExpireAt time.Time +} + +type UserState struct { + UserId int64 + Uuid string + PlayerId int64 + OsType int32 + PlatformType int32 + UserRestrictionType int32 + RegisterDatetime int64 + GameStartDatetime int64 + LatestVersion int64 + + BirthYear int32 + BirthMonth int32 + BackupToken string + ChargeMoneyThisMonth int64 + + Setting UserSettingState + Status UserStatusState + Gem UserGemState + Profile UserProfileState + Login UserLoginState + LoginBonus UserLoginBonusState + Tutorials map[int32]TutorialProgressState + MainQuest MainQuestState + EventQuest EventQuestState + ExtraQuest ExtraQuestState + SideStoryQuests map[int32]SideStoryQuestProgress + SideStoryActiveProgress SideStoryActiveProgress + QuestLimitContentStatus map[int32]QuestLimitContentStatus + + BigHuntProgress BigHuntProgress + BigHuntMaxScores map[int32]BigHuntMaxScore + BigHuntStatuses map[int32]BigHuntStatus + BigHuntScheduleMaxScores map[BigHuntScheduleScoreKey]BigHuntScheduleMaxScore + BigHuntWeeklyMaxScores map[BigHuntWeeklyScoreKey]BigHuntWeeklyMaxScore + BigHuntWeeklyStatuses map[int64]BigHuntWeeklyStatus + BigHuntBattleBinary []byte + BigHuntBattleDetail BigHuntBattleDetail + BigHuntDeckNumber int32 + + Battle BattleState + Gifts GiftState + Gacha GachaState + Notifications NotificationState + + Characters map[int32]CharacterState + Costumes map[string]CostumeState + Weapons map[string]WeaponState + Companions map[string]CompanionState + Thoughts map[string]ThoughtState + DeckCharacters map[string]DeckCharacterState + Decks map[DeckKey]DeckState + Quests map[int32]UserQuestState + QuestMissions map[QuestMissionKey]UserQuestMissionState + Missions map[int32]UserMissionState + WeaponStories map[int32]WeaponStoryState + Gimmick GimmickState + CageOrnamentRewards map[int32]CageOrnamentRewardState + ConsumableItems map[int32]int32 + Materials map[int32]int32 + Parts map[string]PartsState + PartsGroupNotes map[int32]PartsGroupNoteState + PartsPresets map[int32]PartsPresetState + ImportantItems map[int32]int32 + CostumeActiveSkills map[string]CostumeActiveSkillState + WeaponSkills map[string][]WeaponSkillState // key: userWeaponUuid + WeaponAbilities map[string][]WeaponAbilityState // key: userWeaponUuid + DeckTypeNotes map[model.DeckType]DeckTypeNoteState + WeaponNotes map[int32]WeaponNoteState + DeckSubWeapons map[string][]string + DeckParts map[string][]string + NaviCutInPlayed map[int32]bool + ViewedMovies map[int32]int64 + ContentsStories map[int32]int64 + DrawnOmikuji map[int32]int64 + PremiumItems map[int32]int64 + DokanConfirmed map[int32]bool + PortalCageStatus PortalCageStatusState + GuerrillaFreeOpen GuerrillaFreeOpenState + ShopItems map[int32]UserShopItemState + ShopReplaceable UserShopReplaceableState + ShopReplaceableLineup map[int32]UserShopReplaceableLineupState + + Explore ExploreState + ExploreScores map[int32]ExploreScoreState + + CharacterBoards map[int32]CharacterBoardState + CharacterBoardAbilities map[CharacterBoardAbilityKey]CharacterBoardAbilityState + CharacterBoardStatusUps map[CharacterBoardStatusUpKey]CharacterBoardStatusUpState + + CostumeAwakenStatusUps map[CostumeAwakenStatusKey]CostumeAwakenStatusUpState + AutoSaleSettings map[int32]AutoSaleSettingState + CharacterRebirths map[int32]CharacterRebirthState +} + +func (u *UserState) EnsureMaps() { + if u.Tutorials == nil { + u.Tutorials = make(map[int32]TutorialProgressState) + } + if u.Characters == nil { + u.Characters = make(map[int32]CharacterState) + } + if u.Costumes == nil { + u.Costumes = make(map[string]CostumeState) + } + if u.Weapons == nil { + u.Weapons = make(map[string]WeaponState) + } + if u.Companions == nil { + u.Companions = make(map[string]CompanionState) + } + if u.Thoughts == nil { + u.Thoughts = make(map[string]ThoughtState) + } + if u.DeckCharacters == nil { + u.DeckCharacters = make(map[string]DeckCharacterState) + } + if u.Decks == nil { + u.Decks = make(map[DeckKey]DeckState) + } + if u.DeckSubWeapons == nil { + u.DeckSubWeapons = make(map[string][]string) + } + if u.DeckParts == nil { + u.DeckParts = make(map[string][]string) + } + if u.Quests == nil { + u.Quests = make(map[int32]UserQuestState) + } + if u.SideStoryQuests == nil { + u.SideStoryQuests = make(map[int32]SideStoryQuestProgress) + } + if u.QuestLimitContentStatus == nil { + u.QuestLimitContentStatus = make(map[int32]QuestLimitContentStatus) + } + if u.BigHuntMaxScores == nil { + u.BigHuntMaxScores = make(map[int32]BigHuntMaxScore) + } + if u.BigHuntStatuses == nil { + u.BigHuntStatuses = make(map[int32]BigHuntStatus) + } + if u.BigHuntScheduleMaxScores == nil { + u.BigHuntScheduleMaxScores = make(map[BigHuntScheduleScoreKey]BigHuntScheduleMaxScore) + } + if u.BigHuntWeeklyMaxScores == nil { + u.BigHuntWeeklyMaxScores = make(map[BigHuntWeeklyScoreKey]BigHuntWeeklyMaxScore) + } + if u.BigHuntWeeklyStatuses == nil { + u.BigHuntWeeklyStatuses = make(map[int64]BigHuntWeeklyStatus) + } + if u.QuestMissions == nil { + u.QuestMissions = make(map[QuestMissionKey]UserQuestMissionState) + } + if u.Missions == nil { + u.Missions = make(map[int32]UserMissionState) + } + if u.WeaponStories == nil { + u.WeaponStories = make(map[int32]WeaponStoryState) + } + if u.CageOrnamentRewards == nil { + u.CageOrnamentRewards = make(map[int32]CageOrnamentRewardState) + } + if u.ConsumableItems == nil { + u.ConsumableItems = make(map[int32]int32) + } + if u.Materials == nil { + u.Materials = make(map[int32]int32) + } + if u.Parts == nil { + u.Parts = make(map[string]PartsState) + } + if u.PartsGroupNotes == nil { + u.PartsGroupNotes = make(map[int32]PartsGroupNoteState) + } + if u.PartsPresets == nil { + u.PartsPresets = make(map[int32]PartsPresetState) + } + if u.ImportantItems == nil { + u.ImportantItems = make(map[int32]int32) + } + if u.CostumeActiveSkills == nil { + u.CostumeActiveSkills = make(map[string]CostumeActiveSkillState) + } + if u.WeaponSkills == nil { + u.WeaponSkills = make(map[string][]WeaponSkillState) + } + if u.WeaponAbilities == nil { + u.WeaponAbilities = make(map[string][]WeaponAbilityState) + } + if u.DeckTypeNotes == nil { + u.DeckTypeNotes = make(map[model.DeckType]DeckTypeNoteState) + } + if u.WeaponNotes == nil { + u.WeaponNotes = make(map[int32]WeaponNoteState) + } + if u.NaviCutInPlayed == nil { + u.NaviCutInPlayed = make(map[int32]bool) + } + if u.ViewedMovies == nil { + u.ViewedMovies = make(map[int32]int64) + } + if u.ContentsStories == nil { + u.ContentsStories = make(map[int32]int64) + } + if u.DrawnOmikuji == nil { + u.DrawnOmikuji = make(map[int32]int64) + } + if u.PremiumItems == nil { + u.PremiumItems = make(map[int32]int64) + } + if u.DokanConfirmed == nil { + u.DokanConfirmed = make(map[int32]bool) + } + if u.ShopItems == nil { + u.ShopItems = make(map[int32]UserShopItemState) + } + if u.ShopReplaceableLineup == nil { + u.ShopReplaceableLineup = make(map[int32]UserShopReplaceableLineupState) + } + if u.ExploreScores == nil { + u.ExploreScores = make(map[int32]ExploreScoreState) + } + if u.CharacterBoards == nil { + u.CharacterBoards = make(map[int32]CharacterBoardState) + } + if u.CharacterBoardAbilities == nil { + u.CharacterBoardAbilities = make(map[CharacterBoardAbilityKey]CharacterBoardAbilityState) + } + if u.CharacterBoardStatusUps == nil { + u.CharacterBoardStatusUps = make(map[CharacterBoardStatusUpKey]CharacterBoardStatusUpState) + } + if u.CostumeAwakenStatusUps == nil { + u.CostumeAwakenStatusUps = make(map[CostumeAwakenStatusKey]CostumeAwakenStatusUpState) + } + if u.AutoSaleSettings == nil { + u.AutoSaleSettings = make(map[int32]AutoSaleSettingState) + } + if u.CharacterRebirths == nil { + u.CharacterRebirths = make(map[int32]CharacterRebirthState) + } + if u.Gimmick.Progress == nil { + u.Gimmick.Progress = make(map[GimmickKey]GimmickProgressState) + } + if u.Gimmick.OrnamentProgress == nil { + u.Gimmick.OrnamentProgress = make(map[GimmickOrnamentKey]GimmickOrnamentProgressState) + } + if u.Gimmick.Sequences == nil { + u.Gimmick.Sequences = make(map[GimmickSequenceKey]GimmickSequenceState) + } + if u.Gimmick.Unlocks == nil { + u.Gimmick.Unlocks = make(map[GimmickKey]GimmickUnlockState) + } + if u.Gacha.BannerStates == nil { + u.Gacha.BannerStates = make(map[int32]GachaBannerState) + } +} + +type ExploreState struct { + IsUseExploreTicket bool + PlayingExploreId int32 + LatestPlayDatetime int64 + LatestVersion int64 +} + +type ExploreScoreState struct { + ExploreId int32 + MaxScore int32 + MaxScoreUpdateDatetime int64 + LatestVersion int64 +} + +type GuerrillaFreeOpenState struct { + StartDatetime int64 + OpenMinutes int32 + DailyOpenedCount int32 + LatestVersion int64 +} + +type PortalCageStatusState struct { + IsCurrentProgress bool + DropItemStartDatetime int64 + CurrentDropItemCount int32 + LatestVersion int64 +} + +type UserSettingState struct { + IsNotifyPurchaseAlert bool + LatestVersion int64 +} + +type UserStatusState struct { + Level int32 + Exp int32 + StaminaMilliValue int32 + StaminaUpdateDatetime int64 + LatestVersion int64 +} + +type UserGemState struct { + PaidGem int32 + FreeGem int32 +} + +type UserProfileState struct { + Name string + NameUpdateDatetime int64 + Message string + MessageUpdateDatetime int64 + FavoriteCostumeId int32 + FavoriteCostumeIdUpdateDatetime int64 + LatestVersion int64 +} + +type UserLoginState struct { + TotalLoginCount int32 + ContinualLoginCount int32 + MaxContinualLoginCount int32 + LastLoginDatetime int64 + LastComebackLoginDatetime int64 + LatestVersion int64 +} + +type UserLoginBonusState struct { + LoginBonusId int32 + CurrentPageNumber int32 + CurrentStampNumber int32 + LatestRewardReceiveDatetime int64 + LatestVersion int64 +} + +type CharacterState struct { + CharacterId int32 + Level int32 + Exp int32 + LatestVersion int64 +} + +type CostumeState struct { + UserCostumeUuid string + CostumeId int32 + LimitBreakCount int32 + Level int32 + Exp int32 + HeadupDisplayViewId int32 + AcquisitionDatetime int64 + AwakenCount int32 + LatestVersion int64 +} + +type WeaponState struct { + UserWeaponUuid string + WeaponId int32 + Level int32 + Exp int32 + LimitBreakCount int32 + IsProtected bool + AcquisitionDatetime int64 + LatestVersion int64 +} + +type CompanionState struct { + UserCompanionUuid string + CompanionId int32 + HeadupDisplayViewId int32 + Level int32 + AcquisitionDatetime int64 + LatestVersion int64 +} + +type ThoughtState struct { + UserThoughtUuid string + ThoughtId int32 + AcquisitionDatetime int64 + LatestVersion int64 +} + +type DeckCharacterState struct { + UserDeckCharacterUuid string + UserCostumeUuid string + MainUserWeaponUuid string + UserCompanionUuid string + Power int32 + UserThoughtUuid string + DressupCostumeId int32 + LatestVersion int64 +} + +type DeckKey struct { + DeckType model.DeckType + UserDeckNumber int32 +} + +func (k DeckKey) MarshalText() ([]byte, error) { + return marshalKey(int64(k.DeckType), int64(k.UserDeckNumber)), nil +} + +func (k *DeckKey) UnmarshalText(text []byte) error { + v, err := unmarshalKey(text, "DeckKey", 2) + if err != nil { + return err + } + k.DeckType = model.DeckType(v[0]) + k.UserDeckNumber = int32(v[1]) + return nil +} + +type DeckState struct { + DeckType model.DeckType + UserDeckNumber int32 + UserDeckCharacterUuid01 string + UserDeckCharacterUuid02 string + UserDeckCharacterUuid03 string + Name string + Power int32 + LatestVersion int64 +} + +type DeckCharacterInput struct { + UserCostumeUuid string + MainUserWeaponUuid string + SubWeaponUuids []string + PartsUuids []string + UserCompanionUuid string + UserThoughtUuid string + DressupCostumeId int32 +} + +type CostumeActiveSkillState struct { + UserCostumeUuid string + Level int32 + AcquisitionDatetime int64 + LatestVersion int64 +} + +type WeaponSkillState struct { + UserWeaponUuid string + SlotNumber int32 + Level int32 +} + +type WeaponAbilityState struct { + UserWeaponUuid string + SlotNumber int32 + Level int32 +} + +type DeckTypeNoteState struct { + DeckType model.DeckType + MaxDeckPower int32 + LatestVersion int64 +} + +type TutorialProgressState struct { + TutorialType int32 + ProgressPhase int32 + ChoiceId int32 + LatestVersion int64 +} + +type MainQuestState struct { + CurrentQuestFlowType int32 + CurrentMainQuestRouteId int32 + CurrentQuestSceneId int32 + HeadQuestSceneId int32 + IsReachedLastQuestScene bool + ProgressQuestSceneId int32 + ProgressHeadQuestSceneId int32 + ProgressQuestFlowType int32 + MainQuestSeasonId int32 + LatestVersion int64 + + SavedCurrentQuestSceneId int32 + SavedHeadQuestSceneId int32 + ReplayFlowCurrentQuestSceneId int32 + ReplayFlowHeadQuestSceneId int32 +} + +type EventQuestState struct { + CurrentEventQuestChapterId int32 + CurrentQuestId int32 + CurrentQuestSceneId int32 + HeadQuestSceneId int32 + LatestVersion int64 +} + +type ExtraQuestState struct { + CurrentQuestId int32 + CurrentQuestSceneId int32 + HeadQuestSceneId int32 + LatestVersion int64 +} + +type SideStoryQuestProgress struct { + HeadSideStoryQuestSceneId int32 + SideStoryQuestStateType model.SideStoryQuestStateType + LatestVersion int64 +} + +type SideStoryActiveProgress struct { + CurrentSideStoryQuestId int32 + CurrentSideStoryQuestSceneId int32 + LatestVersion int64 +} + +type QuestLimitContentStatus struct { + LimitContentQuestStatusType int32 + EventQuestChapterId int32 + LatestVersion int64 +} + +type BigHuntProgress struct { + CurrentBigHuntBossQuestId int32 + CurrentBigHuntQuestId int32 + CurrentQuestSceneId int32 + IsDryRun bool + LatestVersion int64 +} + +type BigHuntMaxScore struct { + MaxScore int64 + MaxScoreUpdateDatetime int64 + LatestVersion int64 +} + +type BigHuntStatus struct { + DailyChallengeCount int32 + LatestChallengeDatetime int64 + LatestVersion int64 +} + +type BigHuntScheduleScoreKey struct { + BigHuntScheduleId int32 + BigHuntBossId int32 +} + +func (k BigHuntScheduleScoreKey) MarshalText() ([]byte, error) { + return marshalKey(int64(k.BigHuntScheduleId), int64(k.BigHuntBossId)), nil +} + +func (k *BigHuntScheduleScoreKey) UnmarshalText(text []byte) error { + v, err := unmarshalKey(text, "BigHuntScheduleScoreKey", 2) + if err != nil { + return err + } + k.BigHuntScheduleId = int32(v[0]) + k.BigHuntBossId = int32(v[1]) + return nil +} + +type BigHuntScheduleMaxScore struct { + MaxScore int64 + MaxScoreUpdateDatetime int64 + LatestVersion int64 +} + +type BigHuntWeeklyScoreKey struct { + BigHuntWeeklyVersion int64 + AttributeType int32 +} + +func (k BigHuntWeeklyScoreKey) MarshalText() ([]byte, error) { + return marshalKey(k.BigHuntWeeklyVersion, int64(k.AttributeType)), nil +} + +func (k *BigHuntWeeklyScoreKey) UnmarshalText(text []byte) error { + v, err := unmarshalKey(text, "BigHuntWeeklyScoreKey", 2) + if err != nil { + return err + } + k.BigHuntWeeklyVersion = v[0] + k.AttributeType = int32(v[1]) + return nil +} + +type BigHuntWeeklyMaxScore struct { + MaxScore int64 + LatestVersion int64 +} + +type BigHuntWeeklyStatus struct { + IsReceivedWeeklyReward bool + LatestVersion int64 +} + +type BigHuntBattleDetail struct { + DeckType int32 + UserTripleDeckNumber int32 + BossKnockDownCount int32 + MaxComboCount int32 + TotalDamage int64 +} + +type BattleState struct { + IsActive bool + StartCount int32 + FinishCount int32 + LastStartedAt int64 + LastFinishedAt int64 + LastUserPartyCount int32 + LastNpcPartyCount int32 + LastBattleBinarySize int32 + LastElapsedFrameCount int64 +} + +type UserQuestState struct { + QuestId int32 + QuestStateType model.UserQuestStateType + IsBattleOnly bool + UserDeckNumber int32 + LatestStartDatetime int64 + ClearCount int32 + DailyClearCount int32 + LastClearDatetime int64 + ShortestClearFrames int32 + IsRewardGranted bool + LatestVersion int64 +} + +type QuestMissionKey struct { + QuestId int32 + QuestMissionId int32 +} + +func (k QuestMissionKey) MarshalText() ([]byte, error) { + return marshalKey(int64(k.QuestId), int64(k.QuestMissionId)), nil +} + +func (k *QuestMissionKey) UnmarshalText(text []byte) error { + v, err := unmarshalKey(text, "QuestMissionKey", 2) + if err != nil { + return err + } + k.QuestId = int32(v[0]) + k.QuestMissionId = int32(v[1]) + return nil +} + +type UserQuestMissionState struct { + QuestId int32 + QuestMissionId int32 + ProgressValue int32 + IsClear bool + LatestClearDatetime int64 + LatestVersion int64 +} + +type UserMissionState struct { + MissionId int32 + StartDatetime int64 + ProgressValue int32 + MissionProgressStatusType int32 + ClearDatetime int64 + LatestVersion int64 +} + +type WeaponStoryState struct { + WeaponId int32 + ReleasedMaxStoryIndex int32 + LatestVersion int64 +} + +type WeaponNoteState struct { + WeaponId int32 + MaxLevel int32 + MaxLimitBreakCount int32 + FirstAcquisitionDatetime int64 + LatestVersion int64 +} + +type GimmickSequenceKey struct { + GimmickSequenceScheduleId int32 + GimmickSequenceId int32 +} + +func (k GimmickSequenceKey) MarshalText() ([]byte, error) { + return marshalKey(int64(k.GimmickSequenceScheduleId), int64(k.GimmickSequenceId)), nil +} + +func (k *GimmickSequenceKey) UnmarshalText(text []byte) error { + v, err := unmarshalKey(text, "GimmickSequenceKey", 2) + if err != nil { + return err + } + k.GimmickSequenceScheduleId = int32(v[0]) + k.GimmickSequenceId = int32(v[1]) + return nil +} + +type GimmickKey struct { + GimmickSequenceScheduleId int32 + GimmickSequenceId int32 + GimmickId int32 +} + +func (k GimmickKey) MarshalText() ([]byte, error) { + return marshalKey(int64(k.GimmickSequenceScheduleId), int64(k.GimmickSequenceId), int64(k.GimmickId)), nil +} + +func (k *GimmickKey) UnmarshalText(text []byte) error { + v, err := unmarshalKey(text, "GimmickKey", 3) + if err != nil { + return err + } + k.GimmickSequenceScheduleId = int32(v[0]) + k.GimmickSequenceId = int32(v[1]) + k.GimmickId = int32(v[2]) + return nil +} + +type GimmickOrnamentKey struct { + GimmickSequenceScheduleId int32 + GimmickSequenceId int32 + GimmickId int32 + GimmickOrnamentIndex int32 +} + +func (k GimmickOrnamentKey) MarshalText() ([]byte, error) { + return marshalKey(int64(k.GimmickSequenceScheduleId), int64(k.GimmickSequenceId), int64(k.GimmickId), int64(k.GimmickOrnamentIndex)), nil +} + +func (k *GimmickOrnamentKey) UnmarshalText(text []byte) error { + v, err := unmarshalKey(text, "GimmickOrnamentKey", 4) + if err != nil { + return err + } + k.GimmickSequenceScheduleId = int32(v[0]) + k.GimmickSequenceId = int32(v[1]) + k.GimmickId = int32(v[2]) + k.GimmickOrnamentIndex = int32(v[3]) + return nil +} + +type GimmickState struct { + Progress map[GimmickKey]GimmickProgressState + OrnamentProgress map[GimmickOrnamentKey]GimmickOrnamentProgressState + Sequences map[GimmickSequenceKey]GimmickSequenceState + Unlocks map[GimmickKey]GimmickUnlockState +} + +type GimmickProgressState struct { + Key GimmickKey + IsGimmickCleared bool + StartDatetime int64 + LatestVersion int64 +} + +type GimmickOrnamentProgressState struct { + Key GimmickOrnamentKey + ProgressValueBit int32 + BaseDatetime int64 + LatestVersion int64 +} + +type GimmickSequenceState struct { + Key GimmickSequenceKey + IsGimmickSequenceCleared bool + ClearDatetime int64 + LatestVersion int64 +} + +type GimmickUnlockState struct { + Key GimmickKey + IsUnlocked bool + LatestVersion int64 +} + +type CageOrnamentRewardState struct { + CageOrnamentId int32 + AcquisitionDatetime int64 + LatestVersion int64 +} + +type PartsState struct { + UserPartsUuid string + PartsId int32 + Level int32 + PartsStatusMainId int32 + IsProtected bool + AcquisitionDatetime int64 + LatestVersion int64 +} + +type PartsGroupNoteState struct { + PartsGroupId int32 + FirstAcquisitionDatetime int64 + LatestVersion int64 +} + +type PartsPresetState struct { + UserPartsPresetNumber int32 + UserPartsUuid01 string + UserPartsUuid02 string + UserPartsUuid03 string + Name string + UserPartsPresetTagNumber int32 + LatestVersion int64 +} + +type NotificationState struct { + GiftNotReceiveCount int32 + FriendRequestReceiveCount int32 + IsExistUnreadInformation bool +} + +type GiftState struct { + NotReceived []NotReceivedGiftState + Received []ReceivedGiftState +} + +type GiftCommonState struct { + PossessionType int32 + PossessionId int32 + Count int32 + GrantDatetime int64 + DescriptionGiftTextId int32 + EquipmentData []byte +} + +type NotReceivedGiftState struct { + GiftCommon GiftCommonState + ExpirationDatetime int64 + UserGiftUuid string +} + +type ReceivedGiftState struct { + GiftCommon GiftCommonState + ReceivedDatetime int64 +} + +type GachaState struct { + RewardAvailable bool + TodaysCurrentDrawCount int32 + DailyMaxCount int32 + LastRewardDrawDate int64 + ConvertedGachaMedal ConvertedGachaMedalState + BannerStates map[int32]GachaBannerState +} + +type ConvertedGachaMedalState struct { + ConvertedMedalPossession []ConsumableItemState + ObtainPossession *ConsumableItemState +} + +type ConsumableItemState struct { + ConsumableItemId int32 + Count int32 +} + +type UserShopItemState struct { + ShopItemId int32 + BoughtCount int32 + LatestBoughtCountChangedDatetime int64 + LatestVersion int64 +} + +type UserShopReplaceableState struct { + LineupUpdateCount int32 + LatestLineupUpdateDatetime int64 + LatestVersion int64 +} + +type UserShopReplaceableLineupState struct { + SlotNumber int32 + ShopItemId int32 + LatestVersion int64 +} + +type GachaPricePhaseEntry struct { + PhaseId int32 + PriceType int32 + PriceId int32 + Price int32 + RegularPrice int32 + DrawCount int32 + FixedRarityMin int32 + FixedCount int32 + LimitExecCount int32 + StepNumber int32 + Bonuses []GachaBonusEntry +} + +type GachaBonusEntry struct { + PossessionType int32 + PossessionId int32 + Count int32 +} + +type GachaPromotionItem struct { + PossessionType int32 + PossessionId int32 + IsTarget bool + BonusPossessionType int32 + BonusPossessionId int32 +} + +type GachaBannerState struct { + GachaId int32 + MedalCount int32 + StepNumber int32 + LoopCount int32 + DrawCount int32 + BoxDrewCounts map[int32]int32 + BoxNumber int32 +} + +type GachaCatalogEntry struct { + GachaId int32 + GachaLabelType int32 + GachaModeType int32 + GachaAutoResetType int32 + GachaAutoResetPeriod int32 + NextAutoResetDatetime int64 + IsUserGachaUnlock bool + StartDatetime int64 + EndDatetime int64 + RelatedMainQuestChapterId int32 + RelatedEventQuestChapterId int32 + PromotionMovieAssetId int32 + GachaMedalId int32 + MedalConsumableItemId int32 + GachaDecorationType int32 + SortOrder int32 + IsInactive bool + InformationId int32 + BannerAssetName string + GroupId int32 + CeilingCount int32 + PricePhases []GachaPricePhaseEntry + PromotionItems []GachaPromotionItem + DescriptionTextId int32 + MaxStepNumber int32 +} + +type CharacterBoardState struct { + CharacterBoardId int32 + PanelReleaseBit1 int32 + PanelReleaseBit2 int32 + PanelReleaseBit3 int32 + PanelReleaseBit4 int32 + LatestVersion int64 +} + +type CharacterBoardAbilityKey struct { + CharacterId int32 + AbilityId int32 +} + +func (k CharacterBoardAbilityKey) MarshalText() ([]byte, error) { + return marshalKey(int64(k.CharacterId), int64(k.AbilityId)), nil +} + +func (k *CharacterBoardAbilityKey) UnmarshalText(text []byte) error { + v, err := unmarshalKey(text, "CharacterBoardAbilityKey", 2) + if err != nil { + return err + } + k.CharacterId = int32(v[0]) + k.AbilityId = int32(v[1]) + return nil +} + +type CharacterBoardAbilityState struct { + CharacterId int32 + AbilityId int32 + Level int32 + LatestVersion int64 +} + +type CharacterBoardStatusUpKey struct { + CharacterId int32 + StatusCalculationType int32 +} + +func (k CharacterBoardStatusUpKey) MarshalText() ([]byte, error) { + return marshalKey(int64(k.CharacterId), int64(k.StatusCalculationType)), nil +} + +func (k *CharacterBoardStatusUpKey) UnmarshalText(text []byte) error { + v, err := unmarshalKey(text, "CharacterBoardStatusUpKey", 2) + if err != nil { + return err + } + k.CharacterId = int32(v[0]) + k.StatusCalculationType = int32(v[1]) + return nil +} + +type CharacterBoardStatusUpState struct { + CharacterId int32 + StatusCalculationType int32 + Hp int32 + Attack int32 + Vitality int32 + Agility int32 + CriticalRatio int32 + CriticalAttack int32 + LatestVersion int64 +} + +type CostumeAwakenStatusKey struct { + UserCostumeUuid string + StatusCalculationType model.StatusCalculationType +} + +func (k CostumeAwakenStatusKey) MarshalText() ([]byte, error) { + return fmt.Appendf(nil, "%s:%d", k.UserCostumeUuid, k.StatusCalculationType), nil +} + +func (k *CostumeAwakenStatusKey) UnmarshalText(text []byte) error { + s := string(text) + idx := strings.LastIndex(s, ":") + if idx < 0 { + return fmt.Errorf("invalid CostumeAwakenStatusKey: %s", text) + } + k.UserCostumeUuid = s[:idx] + v, err := strconv.ParseInt(s[idx+1:], 10, 32) + if err != nil { + return err + } + k.StatusCalculationType = model.StatusCalculationType(v) + return nil +} + +type CostumeAwakenStatusUpState struct { + UserCostumeUuid string + StatusCalculationType model.StatusCalculationType + Hp int32 + Attack int32 + Vitality int32 + Agility int32 + CriticalRatio int32 + CriticalAttack int32 + LatestVersion int64 +} + +type AutoSaleSettingState struct { + PossessionAutoSaleItemType int32 + PossessionAutoSaleItemValue string +} + +type CharacterRebirthState struct { + CharacterId int32 + RebirthCount int32 + LatestVersion int64 +} diff --git a/server/internal/userdata/diffset.go b/server/internal/userdata/diffset.go new file mode 100644 index 0000000..a585d15 --- /dev/null +++ b/server/internal/userdata/diffset.go @@ -0,0 +1,132 @@ +package userdata + +import ( + "encoding/json" + "fmt" + "strings" + + pb "lunar-tear/server/gen/proto" + "lunar-tear/server/internal/store" +) + +type DiffSet struct { + updates map[string]string + deletes map[string]string +} + +func NewDiffSet(tables map[string]string) *DiffSet { + return &DiffSet{ + updates: tables, + deletes: make(map[string]string), + } +} + +func (ds *DiffSet) WithDeletes(table, deleteKeysJSON string) *DiffSet { + if deleteKeysJSON != "" && deleteKeysJSON != "[]" { + ds.deletes[table] = deleteKeysJSON + } + return ds +} + +func (ds *DiffSet) Build() map[string]*pb.DiffData { + diff := make(map[string]*pb.DiffData, len(ds.updates)) + for table, payload := range ds.updates { + diff[table] = ds.entry(table, payload) + } + return diff +} + +func (ds *DiffSet) BuildOrdered(order []string) map[string]*pb.DiffData { + diff := make(map[string]*pb.DiffData, len(order)) + for _, table := range order { + payload := ds.updates[table] + diff[table] = ds.entry(table, payload) + } + return diff +} + +func (ds *DiffSet) entry(table, payload string) *pb.DiffData { + if payload == "" { + payload = "[]" + } + deleteKeys := "[]" + if dk, ok := ds.deletes[table]; ok { + deleteKeys = dk + } + return &pb.DiffData{ + UpdateRecordsJson: payload, + DeleteKeysJson: deleteKeys, + } +} + +type trackedTable struct { + tableName string + keyFields []string + oldRecords []map[string]any + recordsFn func(store.UserState) []map[string]any +} + +type DeleteTracker struct { + entries []trackedTable +} + +func NewDeleteTracker() *DeleteTracker { + return &DeleteTracker{} +} + +func (dt *DeleteTracker) Track(tableName string, old store.UserState, recordsFn func(store.UserState) []map[string]any, keyFields []string) *DeleteTracker { + dt.entries = append(dt.entries, trackedTable{ + tableName: tableName, + keyFields: keyFields, + oldRecords: recordsFn(old), + recordsFn: recordsFn, + }) + return dt +} + +func (dt *DeleteTracker) Apply(newState store.UserState, tables map[string]string) map[string]*pb.DiffData { + ds := NewDiffSet(tables) + for _, e := range dt.entries { + newRecords := e.recordsFn(newState) + ds.WithDeletes(e.tableName, ComputeDeleteKeys(e.oldRecords, newRecords, e.keyFields)) + } + return ds.Build() +} + +func ComputeDeleteKeys(oldRecords, newRecords []map[string]any, keyFields []string) string { + if len(oldRecords) == 0 { + return "[]" + } + + newSet := make(map[string]struct{}, len(newRecords)) + for _, r := range newRecords { + newSet[compositeKey(r, keyFields)] = struct{}{} + } + + var deleted []map[string]any + for _, r := range oldRecords { + if _, exists := newSet[compositeKey(r, keyFields)]; !exists { + deleted = append(deleted, r) + } + } + + if len(deleted) == 0 { + return "[]" + } + b, err := json.Marshal(deleted) + if err != nil { + return "[]" + } + return string(b) +} + +func compositeKey(record map[string]any, fields []string) string { + var sb strings.Builder + for i, f := range fields { + if i > 0 { + sb.WriteByte('|') + } + sb.WriteString(fmt.Sprint(record[f])) + } + return sb.String() +} diff --git a/server/internal/userdata/empty_diff.go b/server/internal/userdata/empty_diff.go new file mode 100644 index 0000000..32ddf39 --- /dev/null +++ b/server/internal/userdata/empty_diff.go @@ -0,0 +1,9 @@ +package userdata + +import ( + pb "lunar-tear/server/gen/proto" +) + +func EmptyDiff() map[string]*pb.DiffData { + return map[string]*pb.DiffData{} +} diff --git a/server/internal/userdata/proj_bighunt.go b/server/internal/userdata/proj_bighunt.go new file mode 100644 index 0000000..77c9c5e --- /dev/null +++ b/server/internal/userdata/proj_bighunt.go @@ -0,0 +1,159 @@ +package userdata + +import ( + "sort" + + "lunar-tear/server/internal/store" +) + +func init() { + register("IUserBigHuntProgressStatus", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "currentBigHuntBossQuestId": user.BigHuntProgress.CurrentBigHuntBossQuestId, + "currentBigHuntQuestId": user.BigHuntProgress.CurrentBigHuntQuestId, + "currentQuestSceneId": user.BigHuntProgress.CurrentQuestSceneId, + "isDryRun": user.BigHuntProgress.IsDryRun, + "latestVersion": user.BigHuntProgress.LatestVersion, + }) + return s + }) + + register("IUserBigHuntMaxScore", func(user store.UserState) string { + if len(user.BigHuntMaxScores) == 0 { + return "[]" + } + ids := make([]int, 0, len(user.BigHuntMaxScores)) + for id := range user.BigHuntMaxScores { + ids = append(ids, int(id)) + } + sort.Ints(ids) + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + ms := user.BigHuntMaxScores[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "bigHuntBossId": int32(id), + "maxScore": ms.MaxScore, + "maxScoreUpdateDatetime": ms.MaxScoreUpdateDatetime, + "latestVersion": ms.LatestVersion, + }) + } + s, _ := encodeJSONMaps(records...) + return s + }) + + register("IUserBigHuntStatus", func(user store.UserState) string { + if len(user.BigHuntStatuses) == 0 { + return "[]" + } + ids := make([]int, 0, len(user.BigHuntStatuses)) + for id := range user.BigHuntStatuses { + ids = append(ids, int(id)) + } + sort.Ints(ids) + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + st := user.BigHuntStatuses[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "bigHuntBossQuestId": int32(id), + "dailyChallengeCount": st.DailyChallengeCount, + "latestChallengeDatetime": st.LatestChallengeDatetime, + "latestVersion": st.LatestVersion, + }) + } + s, _ := encodeJSONMaps(records...) + return s + }) + + register("IUserBigHuntScheduleMaxScore", func(user store.UserState) string { + if len(user.BigHuntScheduleMaxScores) == 0 { + return "[]" + } + type sortableKey struct { + ScheduleId int32 + BossId int32 + } + keys := make([]sortableKey, 0, len(user.BigHuntScheduleMaxScores)) + for k := range user.BigHuntScheduleMaxScores { + keys = append(keys, sortableKey{k.BigHuntScheduleId, k.BigHuntBossId}) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].ScheduleId != keys[j].ScheduleId { + return keys[i].ScheduleId < keys[j].ScheduleId + } + return keys[i].BossId < keys[j].BossId + }) + records := make([]map[string]any, 0, len(keys)) + for _, k := range keys { + ms := user.BigHuntScheduleMaxScores[store.BigHuntScheduleScoreKey{BigHuntScheduleId: k.ScheduleId, BigHuntBossId: k.BossId}] + records = append(records, map[string]any{ + "userId": user.UserId, + "bigHuntScheduleId": k.ScheduleId, + "bigHuntBossId": k.BossId, + "maxScore": ms.MaxScore, + "maxScoreUpdateDatetime": ms.MaxScoreUpdateDatetime, + "latestVersion": ms.LatestVersion, + }) + } + s, _ := encodeJSONMaps(records...) + return s + }) + + register("IUserBigHuntWeeklyMaxScore", func(user store.UserState) string { + if len(user.BigHuntWeeklyMaxScores) == 0 { + return "[]" + } + type sortableKey struct { + WeeklyVersion int64 + AttributeType int32 + } + keys := make([]sortableKey, 0, len(user.BigHuntWeeklyMaxScores)) + for k := range user.BigHuntWeeklyMaxScores { + keys = append(keys, sortableKey{k.BigHuntWeeklyVersion, k.AttributeType}) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].WeeklyVersion != keys[j].WeeklyVersion { + return keys[i].WeeklyVersion < keys[j].WeeklyVersion + } + return keys[i].AttributeType < keys[j].AttributeType + }) + records := make([]map[string]any, 0, len(keys)) + for _, k := range keys { + ms := user.BigHuntWeeklyMaxScores[store.BigHuntWeeklyScoreKey{BigHuntWeeklyVersion: k.WeeklyVersion, AttributeType: k.AttributeType}] + records = append(records, map[string]any{ + "userId": user.UserId, + "bigHuntWeeklyVersion": k.WeeklyVersion, + "attributeType": k.AttributeType, + "maxScore": ms.MaxScore, + "latestVersion": ms.LatestVersion, + }) + } + s, _ := encodeJSONMaps(records...) + return s + }) + + register("IUserBigHuntWeeklyStatus", func(user store.UserState) string { + if len(user.BigHuntWeeklyStatuses) == 0 { + return "[]" + } + versions := make([]int64, 0, len(user.BigHuntWeeklyStatuses)) + for v := range user.BigHuntWeeklyStatuses { + versions = append(versions, v) + } + sort.Slice(versions, func(i, j int) bool { return versions[i] < versions[j] }) + records := make([]map[string]any, 0, len(versions)) + for _, v := range versions { + ws := user.BigHuntWeeklyStatuses[v] + records = append(records, map[string]any{ + "userId": user.UserId, + "bigHuntWeeklyVersion": v, + "isReceivedWeeklyReward": ws.IsReceivedWeeklyReward, + "latestVersion": ws.LatestVersion, + }) + } + s, _ := encodeJSONMaps(records...) + return s + }) +} diff --git a/server/internal/userdata/proj_characterboard.go b/server/internal/userdata/proj_characterboard.go new file mode 100644 index 0000000..34fcca6 --- /dev/null +++ b/server/internal/userdata/proj_characterboard.go @@ -0,0 +1,109 @@ +package userdata + +import ( + "sort" + + "lunar-tear/server/internal/store" +) + +func init() { + register("IUserCharacterBoard", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedCharacterBoardRecords(user)...) + return s + }) + register("IUserCharacterBoardAbility", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedCharacterBoardAbilityRecords(user)...) + return s + }) + register("IUserCharacterBoardStatusUp", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedCharacterBoardStatusUpRecords(user)...) + return s + }) + registerStatic("IUserCharacterBoardCompleteReward") +} + +func sortedCharacterBoardRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.CharacterBoards)) + for id := range user.CharacterBoards { + ids = append(ids, int(id)) + } + sort.Ints(ids) + + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + row := user.CharacterBoards[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "characterBoardId": row.CharacterBoardId, + "panelReleaseBit1": row.PanelReleaseBit1, + "panelReleaseBit2": row.PanelReleaseBit2, + "panelReleaseBit3": row.PanelReleaseBit3, + "panelReleaseBit4": row.PanelReleaseBit4, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedCharacterBoardAbilityRecords(user store.UserState) []map[string]any { + type entry struct { + key store.CharacterBoardAbilityKey + state store.CharacterBoardAbilityState + } + entries := make([]entry, 0, len(user.CharacterBoardAbilities)) + for k, v := range user.CharacterBoardAbilities { + entries = append(entries, entry{k, v}) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].key.CharacterId != entries[j].key.CharacterId { + return entries[i].key.CharacterId < entries[j].key.CharacterId + } + return entries[i].key.AbilityId < entries[j].key.AbilityId + }) + + records := make([]map[string]any, 0, len(entries)) + for _, e := range entries { + records = append(records, map[string]any{ + "userId": user.UserId, + "characterId": e.state.CharacterId, + "abilityId": e.state.AbilityId, + "level": e.state.Level, + "latestVersion": e.state.LatestVersion, + }) + } + return records +} + +func sortedCharacterBoardStatusUpRecords(user store.UserState) []map[string]any { + type entry struct { + key store.CharacterBoardStatusUpKey + state store.CharacterBoardStatusUpState + } + entries := make([]entry, 0, len(user.CharacterBoardStatusUps)) + for k, v := range user.CharacterBoardStatusUps { + entries = append(entries, entry{k, v}) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].key.CharacterId != entries[j].key.CharacterId { + return entries[i].key.CharacterId < entries[j].key.CharacterId + } + return entries[i].key.StatusCalculationType < entries[j].key.StatusCalculationType + }) + + records := make([]map[string]any, 0, len(entries)) + for _, e := range entries { + records = append(records, map[string]any{ + "userId": user.UserId, + "characterId": e.state.CharacterId, + "statusCalculationType": e.state.StatusCalculationType, + "hp": e.state.Hp, + "attack": e.state.Attack, + "vitality": e.state.Vitality, + "agility": e.state.Agility, + "criticalRatio": e.state.CriticalRatio, + "criticalAttack": e.state.CriticalAttack, + "latestVersion": e.state.LatestVersion, + }) + } + return records +} diff --git a/server/internal/userdata/proj_deck.go b/server/internal/userdata/proj_deck.go new file mode 100644 index 0000000..28f9f73 --- /dev/null +++ b/server/internal/userdata/proj_deck.go @@ -0,0 +1,181 @@ +package userdata + +import ( + "sort" + + "lunar-tear/server/internal/model" + "lunar-tear/server/internal/store" +) + +func init() { + register("IUserDeck", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedDeckRecords(user)...) + return s + }) + register("IUserDeckCharacter", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedDeckCharacterRecords(user)...) + return s + }) + register("IUserDeckSubWeaponGroup", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedDeckSubWeaponGroupRecords(user)...) + return s + }) + register("IUserDeckTypeNote", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedDeckTypeNoteRecords(user)...) + return s + }) + register("IUserDeckPartsGroup", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedDeckPartsGroupRecords(user)...) + return s + }) + register("IUserDeckCharacterDressupCostume", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedDeckDressupCostumeRecords(user)...) + return s + }) + registerStatic( + "IUserDeckLimitContentRestricted", + ) +} + +func sortedDeckRecords(user store.UserState) []map[string]any { + keys := make([]store.DeckKey, 0, len(user.Decks)) + for key := range user.Decks { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].DeckType != keys[j].DeckType { + return keys[i].DeckType < keys[j].DeckType + } + return keys[i].UserDeckNumber < keys[j].UserDeckNumber + }) + + records := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + row := user.Decks[key] + records = append(records, map[string]any{ + "userId": user.UserId, + "deckType": row.DeckType, + "userDeckNumber": row.UserDeckNumber, + "userDeckCharacterUuid01": row.UserDeckCharacterUuid01, + "userDeckCharacterUuid02": row.UserDeckCharacterUuid02, + "userDeckCharacterUuid03": row.UserDeckCharacterUuid03, + "name": row.Name, + "power": row.Power, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedDeckCharacterRecords(user store.UserState) []map[string]any { + keys := sortedStringKeys(user.DeckCharacters) + records := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + row := user.DeckCharacters[key] + records = append(records, map[string]any{ + "userId": user.UserId, + "userDeckCharacterUuid": row.UserDeckCharacterUuid, + "userCostumeUuid": row.UserCostumeUuid, + "mainUserWeaponUuid": row.MainUserWeaponUuid, + "userCompanionUuid": row.UserCompanionUuid, + "power": row.Power, + "userThoughtUuid": row.UserThoughtUuid, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedDeckSubWeaponGroupRecords(user store.UserState) []map[string]any { + keys := sortedStringKeys(user.DeckSubWeapons) + records := make([]map[string]any, 0) + for _, dcUuid := range keys { + weapons := user.DeckSubWeapons[dcUuid] + var lv int64 + if dc, ok := user.DeckCharacters[dcUuid]; ok { + lv = dc.LatestVersion + } + for idx, weaponUuid := range weapons { + records = append(records, map[string]any{ + "userId": user.UserId, + "userDeckCharacterUuid": dcUuid, + "userWeaponUuid": weaponUuid, + "sortOrder": int32(idx + 1), + "latestVersion": lv, + }) + } + } + return records +} + +func sortedDeckTypeNoteRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.DeckTypeNotes)) + for id := range user.DeckTypeNotes { + ids = append(ids, int(id)) + } + sort.Ints(ids) + + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + row := user.DeckTypeNotes[model.DeckType(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "deckType": row.DeckType, + "maxDeckPower": row.MaxDeckPower, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedDeckPartsGroupRecords(user store.UserState) []map[string]any { + keys := sortedStringKeys(user.DeckParts) + records := make([]map[string]any, 0) + for _, dcUuid := range keys { + parts := user.DeckParts[dcUuid] + var lv int64 + if dc, ok := user.DeckCharacters[dcUuid]; ok { + lv = dc.LatestVersion + } + for idx, partsUuid := range parts { + records = append(records, map[string]any{ + "userId": user.UserId, + "userDeckCharacterUuid": dcUuid, + "userPartsUuid": partsUuid, + "sortOrder": int32(idx + 1), + "latestVersion": lv, + }) + } + } + return records +} + +func DeckSubWeaponRecords(user store.UserState) []map[string]any { + return sortedDeckSubWeaponGroupRecords(user) +} + +func DeckPartsGroupRecords(user store.UserState) []map[string]any { + return sortedDeckPartsGroupRecords(user) +} + +func sortedDeckDressupCostumeRecords(user store.UserState) []map[string]any { + keys := sortedStringKeys(user.DeckCharacters) + records := make([]map[string]any, 0) + for _, key := range keys { + row := user.DeckCharacters[key] + if row.DressupCostumeId == 0 { + continue + } + records = append(records, map[string]any{ + "userId": user.UserId, + "userDeckCharacterUuid": row.UserDeckCharacterUuid, + "dressupCostumeId": row.DressupCostumeId, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func DeckDressupCostumeRecords(user store.UserState) []map[string]any { + return sortedDeckDressupCostumeRecords(user) +} diff --git a/server/internal/userdata/proj_gimmick.go b/server/internal/userdata/proj_gimmick.go new file mode 100644 index 0000000..7cdfbd4 --- /dev/null +++ b/server/internal/userdata/proj_gimmick.go @@ -0,0 +1,128 @@ +package userdata + +import ( + "sort" + + "lunar-tear/server/internal/store" +) + +func init() { + register("IUserGimmick", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedGimmickRecords(user)...) + return s + }) + register("IUserGimmickOrnamentProgress", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedGimmickOrnamentProgressRecords(user)...) + return s + }) + register("IUserGimmickSequence", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedGimmickSequenceRecords(user)...) + return s + }) + register("IUserGimmickUnlock", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedGimmickUnlockRecords(user)...) + return s + }) +} + +func sortedGimmickRecords(user store.UserState) []map[string]any { + keys := make([]store.GimmickKey, 0, len(user.Gimmick.Progress)) + for key := range user.Gimmick.Progress { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + return compareGimmickKey(keys[i], keys[j]) < 0 + }) + + records := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + row := user.Gimmick.Progress[key] + records = append(records, map[string]any{ + "userId": user.UserId, + "gimmickSequenceScheduleId": row.Key.GimmickSequenceScheduleId, + "gimmickSequenceId": row.Key.GimmickSequenceId, + "gimmickId": row.Key.GimmickId, + "isGimmickCleared": row.IsGimmickCleared, + "startDatetime": row.StartDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedGimmickOrnamentProgressRecords(user store.UserState) []map[string]any { + keys := make([]store.GimmickOrnamentKey, 0, len(user.Gimmick.OrnamentProgress)) + for key := range user.Gimmick.OrnamentProgress { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + return compareGimmickOrnamentKey(keys[i], keys[j]) < 0 + }) + + records := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + row := user.Gimmick.OrnamentProgress[key] + records = append(records, map[string]any{ + "userId": user.UserId, + "gimmickSequenceScheduleId": row.Key.GimmickSequenceScheduleId, + "gimmickSequenceId": row.Key.GimmickSequenceId, + "gimmickId": row.Key.GimmickId, + "gimmickOrnamentIndex": row.Key.GimmickOrnamentIndex, + "progressValueBit": row.ProgressValueBit, + "baseDatetime": row.BaseDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedGimmickSequenceRecords(user store.UserState) []map[string]any { + keys := make([]store.GimmickSequenceKey, 0, len(user.Gimmick.Sequences)) + for key := range user.Gimmick.Sequences { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].GimmickSequenceScheduleId != keys[j].GimmickSequenceScheduleId { + return keys[i].GimmickSequenceScheduleId < keys[j].GimmickSequenceScheduleId + } + return keys[i].GimmickSequenceId < keys[j].GimmickSequenceId + }) + + records := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + row := user.Gimmick.Sequences[key] + records = append(records, map[string]any{ + "userId": user.UserId, + "gimmickSequenceScheduleId": row.Key.GimmickSequenceScheduleId, + "gimmickSequenceId": row.Key.GimmickSequenceId, + "isGimmickSequenceCleared": row.IsGimmickSequenceCleared, + "clearDatetime": row.ClearDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedGimmickUnlockRecords(user store.UserState) []map[string]any { + keys := make([]store.GimmickKey, 0, len(user.Gimmick.Unlocks)) + for key := range user.Gimmick.Unlocks { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + return compareGimmickKey(keys[i], keys[j]) < 0 + }) + + records := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + row := user.Gimmick.Unlocks[key] + records = append(records, map[string]any{ + "userId": user.UserId, + "gimmickSequenceScheduleId": row.Key.GimmickSequenceScheduleId, + "gimmickSequenceId": row.Key.GimmickSequenceId, + "gimmickId": row.Key.GimmickId, + "isUnlocked": row.IsUnlocked, + "latestVersion": row.LatestVersion, + }) + } + return records +} diff --git a/server/internal/userdata/proj_inventory.go b/server/internal/userdata/proj_inventory.go new file mode 100644 index 0000000..cdaf2ff --- /dev/null +++ b/server/internal/userdata/proj_inventory.go @@ -0,0 +1,583 @@ +package userdata + +import ( + "log" + "sort" + + "lunar-tear/server/internal/gametime" + "lunar-tear/server/internal/store" +) + +func init() { + register("IUserCharacter", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedCharacterRecords(user)...) + return s + }) + register("IUserCostume", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedCostumeRecords(user)...) + return s + }) + register("IUserWeapon", func(user store.UserState) string { + s, _ := encodeJSONMaps(SortedWeaponRecords(user)...) + return s + }) + register("IUserWeaponStory", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedWeaponStoryRecords(user)...) + return s + }) + register("IUserWeaponNote", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedWeaponNoteRecords(user)...) + return s + }) + register("IUserCompanion", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedCompanionRecords(user)...) + return s + }) + register("IUserThought", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedThoughtRecords(user)...) + return s + }) + register("IUserConsumableItem", func(user store.UserState) string { + s, _ := encodeJSONMaps(SortedConsumableItemRecords(user)...) + return s + }) + register("IUserMaterial", func(user store.UserState) string { + s, _ := encodeJSONMaps(SortedMaterialRecords(user)...) + return s + }) + register("IUserImportantItem", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedImportantItemRecords(user)...) + return s + }) + register("IUserPremiumItem", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedPremiumItemRecords(user)...) + return s + }) + register("IUserParts", func(user store.UserState) string { + s, _ := encodeJSONMaps(SortedPartsRecords(user)...) + return s + }) + register("IUserCostumeActiveSkill", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedCostumeActiveSkillRecords(user)...) + return s + }) + register("IUserWeaponSkill", func(user store.UserState) string { + s, _ := encodeJSONMaps(SortedWeaponSkillRecords(user)...) + return s + }) + register("IUserWeaponAbility", func(user store.UserState) string { + s, _ := encodeJSONMaps(SortedWeaponAbilityRecords(user)...) + return s + }) + register("IUserExplore", func(user store.UserState) string { + s, _ := encodeJSONMaps(exploreRecord(user)) + return s + }) + register("IUserExploreScore", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedExploreScoreRecords(user)...) + return s + }) + register("IUserPartsGroupNote", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedPartsGroupNoteRecords(user)...) + return s + }) + register("IUserPartsPreset", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedPartsPresetRecords(user)...) + return s + }) + register("IUserCostumeAwakenStatusUp", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedCostumeAwakenStatusUpRecords(user)...) + return s + }) + register("IUserAutoSaleSettingDetail", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedAutoSaleSettingRecords(user)...) + return s + }) + register("IUserCharacterRebirth", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedCharacterRebirthRecords(user)...) + return s + }) + register("IUserCageOrnamentReward", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedCageOrnamentRewardRecords(user)...) + return s + }) + registerStatic( + "IUserCostumeLevelBonusReleaseStatus", + "IUserCostumeLotteryEffect", + "IUserCostumeLotteryEffectAbility", + "IUserCostumeLotteryEffectStatusUp", + "IUserCostumeLotteryEffectPending", + "IUserWeaponAwaken", + "IUserPartsPresetTag", + "IUserPartsStatusSub", + ) +} + +func sortedCharacterRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.Characters)) + for id := range user.Characters { + ids = append(ids, int(id)) + } + sort.Ints(ids) + + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + row := user.Characters[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "characterId": row.CharacterId, + "level": row.Level, + "exp": row.Exp, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedCostumeRecords(user store.UserState) []map[string]any { + keys := sortedStringKeys(user.Costumes) + records := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + row := user.Costumes[key] + records = append(records, map[string]any{ + "userId": user.UserId, + "userCostumeUuid": row.UserCostumeUuid, + "costumeId": row.CostumeId, + "limitBreakCount": row.LimitBreakCount, + "level": row.Level, + "exp": row.Exp, + "headupDisplayViewId": row.HeadupDisplayViewId, + "acquisitionDatetime": row.AcquisitionDatetime, + "awakenCount": row.AwakenCount, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedAutoSaleSettingRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.AutoSaleSettings)) + for id := range user.AutoSaleSettings { + ids = append(ids, int(id)) + } + sort.Ints(ids) + + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + row := user.AutoSaleSettings[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "possessionAutoSaleItemType": row.PossessionAutoSaleItemType, + "possessionAutoSaleItemValue": row.PossessionAutoSaleItemValue, + "latestVersion": gametime.NowMillis(), + }) + } + return records +} + +func sortedCostumeAwakenStatusUpRecords(user store.UserState) []map[string]any { + keys := make([]store.CostumeAwakenStatusKey, 0, len(user.CostumeAwakenStatusUps)) + for k := range user.CostumeAwakenStatusUps { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].UserCostumeUuid != keys[j].UserCostumeUuid { + return keys[i].UserCostumeUuid < keys[j].UserCostumeUuid + } + return keys[i].StatusCalculationType < keys[j].StatusCalculationType + }) + records := make([]map[string]any, 0, len(keys)) + for _, k := range keys { + row := user.CostumeAwakenStatusUps[k] + records = append(records, map[string]any{ + "userId": user.UserId, + "userCostumeUuid": row.UserCostumeUuid, + "statusCalculationType": int32(row.StatusCalculationType), + "hp": row.Hp, + "attack": row.Attack, + "vitality": row.Vitality, + "agility": row.Agility, + "criticalRatio": row.CriticalRatio, + "criticalAttack": row.CriticalAttack, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func SortedWeaponRecords(user store.UserState) []map[string]any { + keys := sortedStringKeys(user.Weapons) + records := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + row := user.Weapons[key] + uuid := row.UserWeaponUuid + if uuid == "" { + log.Printf("[userdata] sortedWeaponRecords: using key as fallback for weapon key=%q (empty userWeaponUuid)", key) + uuid = key + } + records = append(records, map[string]any{ + "userId": user.UserId, + "userWeaponUuid": uuid, + "weaponId": row.WeaponId, + "level": row.Level, + "exp": row.Exp, + "limitBreakCount": row.LimitBreakCount, + "isProtected": row.IsProtected, + "acquisitionDatetime": row.AcquisitionDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedWeaponStoryRecords(user store.UserState) []map[string]any { + if user.WeaponStories == nil { + return []map[string]any{} + } + weaponIdsInWeapons := make(map[int32]bool) + for _, row := range user.Weapons { + weaponIdsInWeapons[row.WeaponId] = true + } + weaponIds := make([]int32, 0, len(user.WeaponStories)) + for weaponId := range user.WeaponStories { + if weaponIdsInWeapons[weaponId] { + weaponIds = append(weaponIds, weaponId) + } + } + sort.Slice(weaponIds, func(i, j int) bool { return weaponIds[i] < weaponIds[j] }) + records := make([]map[string]any, 0, len(weaponIds)) + for _, weaponId := range weaponIds { + row := user.WeaponStories[weaponId] + records = append(records, map[string]any{ + "userId": user.UserId, + "weaponId": row.WeaponId, + "releasedMaxStoryIndex": row.ReleasedMaxStoryIndex, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedWeaponNoteRecords(user store.UserState) []map[string]any { + weaponIds := make([]int32, 0, len(user.WeaponNotes)) + for id := range user.WeaponNotes { + weaponIds = append(weaponIds, id) + } + sort.Slice(weaponIds, func(i, j int) bool { return weaponIds[i] < weaponIds[j] }) + records := make([]map[string]any, 0, len(weaponIds)) + for _, id := range weaponIds { + row := user.WeaponNotes[id] + records = append(records, map[string]any{ + "userId": user.UserId, + "weaponId": row.WeaponId, + "maxLevel": row.MaxLevel, + "maxLimitBreakCount": row.MaxLimitBreakCount, + "firstAcquisitionDatetime": row.FirstAcquisitionDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedCompanionRecords(user store.UserState) []map[string]any { + keys := sortedStringKeys(user.Companions) + records := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + row := user.Companions[key] + records = append(records, map[string]any{ + "userId": user.UserId, + "userCompanionUuid": row.UserCompanionUuid, + "companionId": row.CompanionId, + "headupDisplayViewId": row.HeadupDisplayViewId, + "level": row.Level, + "acquisitionDatetime": row.AcquisitionDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedThoughtRecords(user store.UserState) []map[string]any { + keys := sortedStringKeys(user.Thoughts) + records := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + row := user.Thoughts[key] + records = append(records, map[string]any{ + "userId": user.UserId, + "userThoughtUuid": row.UserThoughtUuid, + "thoughtId": row.ThoughtId, + "acquisitionDatetime": row.AcquisitionDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func SortedConsumableItemRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.ConsumableItems)) + for id := range user.ConsumableItems { + ids = append(ids, int(id)) + } + sort.Ints(ids) + + nowMillis := gametime.NowMillis() + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + records = append(records, map[string]any{ + "userId": user.UserId, + "consumableItemId": int32(id), + "count": user.ConsumableItems[int32(id)], + "firstAcquisitionDatetime": nowMillis, + "latestVersion": nowMillis, + }) + } + return records +} + +func SortedMaterialRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.Materials)) + for id := range user.Materials { + ids = append(ids, int(id)) + } + sort.Ints(ids) + + nowMillis := gametime.NowMillis() + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + records = append(records, map[string]any{ + "userId": user.UserId, + "materialId": int32(id), + "count": user.Materials[int32(id)], + "firstAcquisitionDatetime": nowMillis, + "latestVersion": nowMillis, + }) + } + return records +} + +func sortedImportantItemRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.ImportantItems)) + for id := range user.ImportantItems { + ids = append(ids, int(id)) + } + sort.Ints(ids) + + nowMillis := gametime.NowMillis() + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + records = append(records, map[string]any{ + "userId": user.UserId, + "importantItemId": int32(id), + "count": user.ImportantItems[int32(id)], + "firstAcquisitionDatetime": nowMillis, + "latestVersion": nowMillis, + }) + } + return records +} + +func sortedPremiumItemRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.PremiumItems)) + for id := range user.PremiumItems { + ids = append(ids, int(id)) + } + sort.Ints(ids) + + nowMillis := gametime.NowMillis() + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + acqTime := user.PremiumItems[int32(id)] + if acqTime == 0 { + acqTime = nowMillis + } + records = append(records, map[string]any{ + "userId": user.UserId, + "premiumItemId": int32(id), + "acquisitionDatetime": acqTime, + "latestVersion": nowMillis, + }) + } + return records +} + +func SortedPartsRecords(user store.UserState) []map[string]any { + keys := sortedStringKeys(user.Parts) + records := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + row := user.Parts[key] + records = append(records, map[string]any{ + "userId": user.UserId, + "userPartsUuid": row.UserPartsUuid, + "partsId": row.PartsId, + "level": row.Level, + "partsStatusMainId": row.PartsStatusMainId, + "isProtected": row.IsProtected, + "acquisitionDatetime": row.AcquisitionDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedPartsGroupNoteRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.PartsGroupNotes)) + for id := range user.PartsGroupNotes { + ids = append(ids, int(id)) + } + sort.Ints(ids) + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + row := user.PartsGroupNotes[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "partsGroupId": row.PartsGroupId, + "firstAcquisitionDatetime": row.FirstAcquisitionDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedPartsPresetRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.PartsPresets)) + for id := range user.PartsPresets { + ids = append(ids, int(id)) + } + sort.Ints(ids) + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + row := user.PartsPresets[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "userPartsPresetNumber": row.UserPartsPresetNumber, + "userPartsUuid01": row.UserPartsUuid01, + "userPartsUuid02": row.UserPartsUuid02, + "userPartsUuid03": row.UserPartsUuid03, + "name": row.Name, + "userPartsPresetTagNumber": row.UserPartsPresetTagNumber, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedCostumeActiveSkillRecords(user store.UserState) []map[string]any { + keys := sortedStringKeys(user.CostumeActiveSkills) + records := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + row := user.CostumeActiveSkills[key] + records = append(records, map[string]any{ + "userId": user.UserId, + "userCostumeUuid": row.UserCostumeUuid, + "level": row.Level, + "acquisitionDatetime": row.AcquisitionDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func SortedWeaponSkillRecords(user store.UserState) []map[string]any { + keys := sortedStringKeys(user.WeaponSkills) + records := make([]map[string]any, 0) + for _, key := range keys { + for _, row := range user.WeaponSkills[key] { + records = append(records, map[string]any{ + "userId": user.UserId, + "userWeaponUuid": row.UserWeaponUuid, + "slotNumber": row.SlotNumber, + "level": row.Level, + "latestVersion": int64(0), + }) + } + } + return records +} + +func SortedWeaponAbilityRecords(user store.UserState) []map[string]any { + keys := sortedStringKeys(user.WeaponAbilities) + records := make([]map[string]any, 0) + for _, key := range keys { + for _, row := range user.WeaponAbilities[key] { + records = append(records, map[string]any{ + "userId": user.UserId, + "userWeaponUuid": row.UserWeaponUuid, + "slotNumber": row.SlotNumber, + "level": row.Level, + "latestVersion": int64(0), + }) + } + } + return records +} + +func exploreRecord(user store.UserState) map[string]any { + return map[string]any{ + "userId": user.UserId, + "isUseExploreTicket": user.Explore.IsUseExploreTicket, + "playingExploreId": user.Explore.PlayingExploreId, + "latestPlayDatetime": user.Explore.LatestPlayDatetime, + "latestVersion": user.Explore.LatestVersion, + } +} + +func sortedCharacterRebirthRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.CharacterRebirths)) + for id := range user.CharacterRebirths { + ids = append(ids, int(id)) + } + sort.Ints(ids) + + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + row := user.CharacterRebirths[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "characterId": row.CharacterId, + "rebirthCount": row.RebirthCount, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedExploreScoreRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.ExploreScores)) + for id := range user.ExploreScores { + ids = append(ids, int(id)) + } + sort.Ints(ids) + + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + row := user.ExploreScores[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "exploreId": row.ExploreId, + "maxScore": row.MaxScore, + "maxScoreUpdateDatetime": row.MaxScoreUpdateDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedCageOrnamentRewardRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.CageOrnamentRewards)) + for id := range user.CageOrnamentRewards { + ids = append(ids, int(id)) + } + sort.Ints(ids) + + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + row := user.CageOrnamentRewards[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "cageOrnamentId": row.CageOrnamentId, + "acquisitionDatetime": row.AcquisitionDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} diff --git a/server/internal/userdata/proj_quest.go b/server/internal/userdata/proj_quest.go new file mode 100644 index 0000000..4060757 --- /dev/null +++ b/server/internal/userdata/proj_quest.go @@ -0,0 +1,203 @@ +package userdata + +import ( + "sort" + + "lunar-tear/server/internal/store" +) + +func sortedQuestRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.Quests)) + for id := range user.Quests { + ids = append(ids, int(id)) + } + sort.Ints(ids) + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + row := user.Quests[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "questId": row.QuestId, + "questStateType": row.QuestStateType, + "isBattleOnly": row.IsBattleOnly, + "latestStartDatetime": row.LatestStartDatetime, + "clearCount": row.ClearCount, + "dailyClearCount": row.DailyClearCount, + "lastClearDatetime": row.LastClearDatetime, + "shortestClearFrames": row.ShortestClearFrames, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedQuestMissionRecords(user store.UserState) []map[string]any { + keys := make([]store.QuestMissionKey, 0, len(user.QuestMissions)) + for key := range user.QuestMissions { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].QuestId != keys[j].QuestId { + return keys[i].QuestId < keys[j].QuestId + } + return keys[i].QuestMissionId < keys[j].QuestMissionId + }) + records := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + row := user.QuestMissions[key] + records = append(records, map[string]any{ + "userId": user.UserId, + "questId": row.QuestId, + "questMissionId": row.QuestMissionId, + "progressValue": row.ProgressValue, + "isClear": row.IsClear, + "latestClearDatetime": row.LatestClearDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func init() { + register("IUserQuest", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedQuestRecords(user)...) + return s + }) + register("IUserQuestMission", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedQuestMissionRecords(user)...) + return s + }) + register("IUserMainQuestFlowStatus", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "currentQuestFlowType": user.MainQuest.CurrentQuestFlowType, + "latestVersion": user.MainQuest.LatestVersion, + }) + return s + }) + register("IUserMainQuestMainFlowStatus", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "currentMainQuestRouteId": user.MainQuest.CurrentMainQuestRouteId, + "currentQuestSceneId": user.MainQuest.CurrentQuestSceneId, + "headQuestSceneId": user.MainQuest.HeadQuestSceneId, + "isReachedLastQuestScene": user.MainQuest.IsReachedLastQuestScene, + "latestVersion": user.MainQuest.LatestVersion, + }) + return s + }) + register("IUserMainQuestProgressStatus", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "currentQuestSceneId": user.MainQuest.ProgressQuestSceneId, + "headQuestSceneId": user.MainQuest.ProgressHeadQuestSceneId, + "currentQuestFlowType": user.MainQuest.ProgressQuestFlowType, + "latestVersion": user.MainQuest.LatestVersion, + }) + return s + }) + register("IUserMainQuestSeasonRoute", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "mainQuestSeasonId": user.MainQuest.MainQuestSeasonId, + "mainQuestRouteId": user.MainQuest.CurrentMainQuestRouteId, + "latestVersion": user.MainQuest.LatestVersion, + }) + return s + }) + register("IUserEventQuestProgressStatus", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "currentEventQuestChapterId": user.EventQuest.CurrentEventQuestChapterId, + "currentQuestId": user.EventQuest.CurrentQuestId, + "currentQuestSceneId": user.EventQuest.CurrentQuestSceneId, + "headQuestSceneId": user.EventQuest.HeadQuestSceneId, + "latestVersion": user.EventQuest.LatestVersion, + }) + return s + }) + register("IUserExtraQuestProgressStatus", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "currentQuestId": user.ExtraQuest.CurrentQuestId, + "currentQuestSceneId": user.ExtraQuest.CurrentQuestSceneId, + "headQuestSceneId": user.ExtraQuest.HeadQuestSceneId, + "latestVersion": user.ExtraQuest.LatestVersion, + }) + return s + }) + register("IUserMainQuestReplayFlowStatus", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "currentHeadQuestSceneId": user.MainQuest.ReplayFlowHeadQuestSceneId, + "currentQuestSceneId": user.MainQuest.ReplayFlowCurrentQuestSceneId, + "latestVersion": user.MainQuest.LatestVersion, + }) + return s + }) + register("IUserSideStoryQuestSceneProgressStatus", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "currentSideStoryQuestId": user.SideStoryActiveProgress.CurrentSideStoryQuestId, + "currentSideStoryQuestSceneId": user.SideStoryActiveProgress.CurrentSideStoryQuestSceneId, + "latestVersion": user.SideStoryActiveProgress.LatestVersion, + }) + return s + }) + register("IUserSideStoryQuest", func(user store.UserState) string { + if len(user.SideStoryQuests) == 0 { + return "[]" + } + ids := make([]int, 0, len(user.SideStoryQuests)) + for id := range user.SideStoryQuests { + ids = append(ids, int(id)) + } + sort.Ints(ids) + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + progress := user.SideStoryQuests[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "sideStoryQuestId": int32(id), + "headSideStoryQuestSceneId": progress.HeadSideStoryQuestSceneId, + "sideStoryQuestStateType": progress.SideStoryQuestStateType, + "latestVersion": progress.LatestVersion, + }) + } + s, _ := encodeJSONMaps(records...) + return s + }) + register("IUserQuestLimitContentStatus", func(user store.UserState) string { + if len(user.QuestLimitContentStatus) == 0 { + return "[]" + } + ids := make([]int, 0, len(user.QuestLimitContentStatus)) + for id := range user.QuestLimitContentStatus { + ids = append(ids, int(id)) + } + sort.Ints(ids) + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + st := user.QuestLimitContentStatus[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "questId": int32(id), + "limitContentQuestStatusType": st.LimitContentQuestStatusType, + "eventQuestChapterId": st.EventQuestChapterId, + "latestVersion": st.LatestVersion, + }) + } + s, _ := encodeJSONMaps(records...) + return s + }) + registerStatic( + "IUserEventQuestDailyGroupCompleteReward", + "IUserEventQuestLabyrinthSeason", + "IUserEventQuestLabyrinthStage", + "IUserEventQuestTowerAccumulationReward", + "IUserQuestReplayFlowRewardGroup", + "IUserQuestAutoOrbit", + "IUserQuestSceneChoice", + "IUserQuestSceneChoiceHistory", + ) +} diff --git a/server/internal/userdata/proj_user.go b/server/internal/userdata/proj_user.go new file mode 100644 index 0000000..0cd54f1 --- /dev/null +++ b/server/internal/userdata/proj_user.go @@ -0,0 +1,333 @@ +package userdata + +import ( + "sort" + + "lunar-tear/server/internal/gametime" + "lunar-tear/server/internal/store" +) + +func init() { + register("IUser", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "playerId": user.PlayerId, + "osType": user.OsType, + "platformType": user.PlatformType, + "userRestrictionType": user.UserRestrictionType, + "registerDatetime": user.RegisterDatetime, + "gameStartDatetime": user.GameStartDatetime, + "latestVersion": user.LatestVersion, + }) + return s + }) + register("IUserSetting", func(user store.UserState) string { + s, _ := encodeJSONRecords(&EntityIUserSetting{ + UserId: user.UserId, + IsNotifyPurchaseAlert: user.Setting.IsNotifyPurchaseAlert, + LatestVersion: user.Setting.LatestVersion, + }) + return s + }) + register("IUserStatus", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "level": user.Status.Level, + "exp": user.Status.Exp, + "staminaMilliValue": user.Status.StaminaMilliValue, + "staminaUpdateDatetime": user.Status.StaminaUpdateDatetime, + "latestVersion": user.Status.LatestVersion, + }) + return s + }) + register("IUserGem", func(user store.UserState) string { + s, _ := encodeJSONRecords(&EntityIUserGem{ + UserId: user.UserId, + PaidGem: user.Gem.PaidGem, + FreeGem: user.Gem.FreeGem, + }) + return s + }) + register("IUserProfile", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "name": user.Profile.Name, + "nameUpdateDatetime": user.Profile.NameUpdateDatetime, + "message": user.Profile.Message, + "messageUpdateDatetime": user.Profile.MessageUpdateDatetime, + "favoriteCostumeId": user.Profile.FavoriteCostumeId, + "favoriteCostumeIdUpdateDatetime": user.Profile.FavoriteCostumeIdUpdateDatetime, + "latestVersion": user.Profile.LatestVersion, + }) + return s + }) + register("IUserLogin", func(user store.UserState) string { + s, _ := encodeJSONRecords(&EntityIUserLogin{ + UserId: user.UserId, + TotalLoginCount: user.Login.TotalLoginCount, + ContinualLoginCount: user.Login.ContinualLoginCount, + MaxContinualLoginCount: user.Login.MaxContinualLoginCount, + LastLoginDatetime: user.Login.LastLoginDatetime, + LastComebackLoginDatetime: user.Login.LastComebackLoginDatetime, + LatestVersion: user.Login.LatestVersion, + }) + return s + }) + register("IUserLoginBonus", func(user store.UserState) string { + s, _ := encodeJSONRecords(&EntityIUserLoginBonus{ + UserId: user.UserId, + LoginBonusId: user.LoginBonus.LoginBonusId, + CurrentPageNumber: user.LoginBonus.CurrentPageNumber, + CurrentStampNumber: user.LoginBonus.CurrentStampNumber, + LatestRewardReceiveDatetime: user.LoginBonus.LatestRewardReceiveDatetime, + LatestVersion: user.LoginBonus.LatestVersion, + }) + return s + }) + register("IUserTutorialProgress", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedTutorialRecords(user)...) + return s + }) + register("IUserMission", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedMissionRecords(user)...) + return s + }) + register("IUserNaviCutIn", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedNaviCutInRecords(user)...) + return s + }) + register("IUserMovie", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedMovieRecords(user)...) + return s + }) + register("IUserContentsStory", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedContentsStoryRecords(user)...) + return s + }) + register("IUserOmikuji", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedOmikujiRecords(user)...) + return s + }) + register("IUserDokan", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedDokanRecords(user)...) + return s + }) + register("IUserPortalCageStatus", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "isCurrentProgress": user.PortalCageStatus.IsCurrentProgress, + "dropItemStartDatetime": user.PortalCageStatus.DropItemStartDatetime, + "currentDropItemCount": user.PortalCageStatus.CurrentDropItemCount, + "latestVersion": user.PortalCageStatus.LatestVersion, + }) + return s + }) + register("IUserEventQuestGuerrillaFreeOpen", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "startDatetime": user.GuerrillaFreeOpen.StartDatetime, + "openMinutes": user.GuerrillaFreeOpen.OpenMinutes, + "dailyOpenedCount": user.GuerrillaFreeOpen.DailyOpenedCount, + "latestVersion": user.GuerrillaFreeOpen.LatestVersion, + }) + return s + }) + + register("IUserShopItem", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedShopItemRecords(user)...) + return s + }) + register("IUserShopReplaceable", func(user store.UserState) string { + s, _ := encodeJSONMaps(map[string]any{ + "userId": user.UserId, + "lineupUpdateCount": user.ShopReplaceable.LineupUpdateCount, + "latestLineupUpdateDatetime": user.ShopReplaceable.LatestLineupUpdateDatetime, + "latestVersion": user.ShopReplaceable.LatestVersion, + }) + return s + }) + register("IUserShopReplaceableLineup", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedShopReplaceableLineupRecords(user)...) + return s + }) + + registerStatic() +} + +func sortedTutorialRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.Tutorials)) + for id := range user.Tutorials { + ids = append(ids, int(id)) + } + sort.Ints(ids) + + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + row := user.Tutorials[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "tutorialType": row.TutorialType, + "progressPhase": row.ProgressPhase, + "choiceId": row.ChoiceId, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedMissionRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.Missions)) + for id := range user.Missions { + ids = append(ids, int(id)) + } + sort.Ints(ids) + + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + row := user.Missions[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "missionId": row.MissionId, + "startDatetime": row.StartDatetime, + "progressValue": row.ProgressValue, + "missionProgressStatusType": row.MissionProgressStatusType, + "clearDatetime": row.ClearDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedNaviCutInRecords(user store.UserState) []map[string]any { + ids := make([]int32, 0, len(user.NaviCutInPlayed)) + for id := range user.NaviCutInPlayed { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + now := gametime.NowMillis() + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + records = append(records, map[string]any{ + "userId": user.UserId, + "naviCutInId": id, + "playDatetime": now, + "latestVersion": now, + }) + } + return records +} + +func sortedContentsStoryRecords(user store.UserState) []map[string]any { + ids := make([]int32, 0, len(user.ContentsStories)) + for id := range user.ContentsStories { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + now := gametime.NowMillis() + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + records = append(records, map[string]any{ + "userId": user.UserId, + "contentsStoryId": id, + "playDatetime": user.ContentsStories[id], + "latestVersion": now, + }) + } + return records +} + +func sortedMovieRecords(user store.UserState) []map[string]any { + ids := make([]int32, 0, len(user.ViewedMovies)) + for id := range user.ViewedMovies { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + now := gametime.NowMillis() + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + records = append(records, map[string]any{ + "userId": user.UserId, + "movieId": id, + "latestViewedDatetime": user.ViewedMovies[id], + "latestVersion": now, + }) + } + return records +} + +func sortedOmikujiRecords(user store.UserState) []map[string]any { + ids := make([]int32, 0, len(user.DrawnOmikuji)) + for id := range user.DrawnOmikuji { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + now := gametime.NowMillis() + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + records = append(records, map[string]any{ + "userId": user.UserId, + "omikujiId": id, + "latestDrawDatetime": user.DrawnOmikuji[id], + "latestVersion": now, + }) + } + return records +} + +func sortedDokanRecords(user store.UserState) []map[string]any { + ids := make([]int32, 0, len(user.DokanConfirmed)) + for id := range user.DokanConfirmed { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + now := gametime.NowMillis() + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + records = append(records, map[string]any{ + "userId": user.UserId, + "dokanId": id, + "displayDatetime": now, + "latestVersion": now, + }) + } + return records +} + +func sortedShopItemRecords(user store.UserState) []map[string]any { + ids := make([]int, 0, len(user.ShopItems)) + for id := range user.ShopItems { + ids = append(ids, int(id)) + } + sort.Ints(ids) + records := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + row := user.ShopItems[int32(id)] + records = append(records, map[string]any{ + "userId": user.UserId, + "shopItemId": row.ShopItemId, + "boughtCount": row.BoughtCount, + "latestBoughtCountChangedDatetime": row.LatestBoughtCountChangedDatetime, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedShopReplaceableLineupRecords(user store.UserState) []map[string]any { + slots := make([]int, 0, len(user.ShopReplaceableLineup)) + for slot := range user.ShopReplaceableLineup { + slots = append(slots, int(slot)) + } + sort.Ints(slots) + records := make([]map[string]any, 0, len(slots)) + for _, slot := range slots { + row := user.ShopReplaceableLineup[int32(slot)] + records = append(records, map[string]any{ + "userId": user.UserId, + "slotNumber": row.SlotNumber, + "shopItemId": row.ShopItemId, + "latestVersion": row.LatestVersion, + }) + } + return records +} diff --git a/server/internal/userdata/projector.go b/server/internal/userdata/projector.go new file mode 100644 index 0000000..ee9b60b --- /dev/null +++ b/server/internal/userdata/projector.go @@ -0,0 +1,88 @@ +package userdata + +import ( + "sort" + + "lunar-tear/server/internal/store" +) + +type Projector func(user store.UserState) string + +var projectors = make(map[string]Projector) + +func register(tableName string, fn Projector) { + projectors[tableName] = fn +} + +func registerStatic(tableNames ...string) { + for _, name := range tableNames { + projectors[name] = func(_ store.UserState) string { return "[]" } + } +} + +func projectTable(tableName string, user store.UserState) string { + fn, ok := projectors[tableName] + if !ok { + return "[]" + } + s := fn(user) + if s == "" { + return "[]" + } + return s +} + +func sortedStringKeys[T any](rows map[string]T) []string { + keys := make([]string, 0, len(rows)) + for key := range rows { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func compareGimmickKey(a, b store.GimmickKey) int { + if a.GimmickSequenceScheduleId != b.GimmickSequenceScheduleId { + if a.GimmickSequenceScheduleId < b.GimmickSequenceScheduleId { + return -1 + } + return 1 + } + if a.GimmickSequenceId != b.GimmickSequenceId { + if a.GimmickSequenceId < b.GimmickSequenceId { + return -1 + } + return 1 + } + if a.GimmickId < b.GimmickId { + return -1 + } + if a.GimmickId > b.GimmickId { + return 1 + } + return 0 +} + +func compareGimmickOrnamentKey(a, b store.GimmickOrnamentKey) int { + if cmp := compareGimmickKey( + store.GimmickKey{ + GimmickSequenceScheduleId: a.GimmickSequenceScheduleId, + GimmickSequenceId: a.GimmickSequenceId, + GimmickId: a.GimmickId, + }, + store.GimmickKey{ + GimmickSequenceScheduleId: b.GimmickSequenceScheduleId, + GimmickSequenceId: b.GimmickSequenceId, + GimmickId: b.GimmickId, + }, + ); cmp != 0 { + return cmp + } + if a.GimmickOrnamentIndex < b.GimmickOrnamentIndex { + return -1 + } + if a.GimmickOrnamentIndex > b.GimmickOrnamentIndex { + return 1 + } + return 0 +} diff --git a/server/internal/userdata/state_projection.go b/server/internal/userdata/state_projection.go new file mode 100644 index 0000000..e257c80 --- /dev/null +++ b/server/internal/userdata/state_projection.go @@ -0,0 +1,172 @@ +package userdata + +import ( + pb "lunar-tear/server/gen/proto" + + "lunar-tear/server/internal/store" +) + +func FullClientTableMap(user store.UserState) map[string]string { + return map[string]string{ + "IUser": projectTable("IUser", user), + "IUserSetting": projectTable("IUserSetting", user), + "IUserStatus": projectTable("IUserStatus", user), + "IUserGem": projectTable("IUserGem", user), + "IUserProfile": projectTable("IUserProfile", user), + "IUserCharacter": projectTable("IUserCharacter", user), + "IUserCostume": projectTable("IUserCostume", user), + "IUserWeapon": projectTable("IUserWeapon", user), + "IUserWeaponStory": projectTable("IUserWeaponStory", user), + "IUserCompanion": projectTable("IUserCompanion", user), + "IUserThought": projectTable("IUserThought", user), + "IUserDeckCharacter": projectTable("IUserDeckCharacter", user), + "IUserDeck": projectTable("IUserDeck", user), + "IUserLogin": projectTable("IUserLogin", user), + "IUserLoginBonus": projectTable("IUserLoginBonus", user), + "IUserMission": projectTable("IUserMission", user), + "IUserMainQuestFlowStatus": projectTable("IUserMainQuestFlowStatus", user), + "IUserMainQuestMainFlowStatus": projectTable("IUserMainQuestMainFlowStatus", user), + "IUserMainQuestProgressStatus": projectTable("IUserMainQuestProgressStatus", user), + "IUserMainQuestSeasonRoute": projectTable("IUserMainQuestSeasonRoute", user), + "IUserQuest": projectTable("IUserQuest", user), + "IUserQuestMission": projectTable("IUserQuestMission", user), + "IUserTutorialProgress": projectTable("IUserTutorialProgress", user), + "IUserGimmick": projectTable("IUserGimmick", user), + "IUserGimmickOrnamentProgress": projectTable("IUserGimmickOrnamentProgress", user), + "IUserGimmickSequence": projectTable("IUserGimmickSequence", user), + "IUserGimmickUnlock": projectTable("IUserGimmickUnlock", user), + "IUserMaterial": projectTable("IUserMaterial", user), + "IUserConsumableItem": projectTable("IUserConsumableItem", user), + "IUserParts": projectTable("IUserParts", user), + "IUserImportantItem": projectTable("IUserImportantItem", user), + "IUserPremiumItem": projectTable("IUserPremiumItem", user), + "IUserDeckPartsGroup": projectTable("IUserDeckPartsGroup", user), + "IUserDeckSubWeaponGroup": projectTable("IUserDeckSubWeaponGroup", user), + "IUserDeckCharacterDressupCostume": projectTable("IUserDeckCharacterDressupCostume", user), + "IUserDeckTypeNote": projectTable("IUserDeckTypeNote", user), + "IUserDeckLimitContentRestricted": projectTable("IUserDeckLimitContentRestricted", user), + "IUserCostumeActiveSkill": projectTable("IUserCostumeActiveSkill", user), + "IUserCostumeAwakenStatusUp": projectTable("IUserCostumeAwakenStatusUp", user), + "IUserCostumeLevelBonusReleaseStatus": projectTable("IUserCostumeLevelBonusReleaseStatus", user), + "IUserCostumeLotteryEffect": projectTable("IUserCostumeLotteryEffect", user), + "IUserCostumeLotteryEffectAbility": projectTable("IUserCostumeLotteryEffectAbility", user), + "IUserCostumeLotteryEffectStatusUp": projectTable("IUserCostumeLotteryEffectStatusUp", user), + "IUserCostumeLotteryEffectPending": projectTable("IUserCostumeLotteryEffectPending", user), + "IUserWeaponNote": projectTable("IUserWeaponNote", user), + "IUserWeaponAbility": projectTable("IUserWeaponAbility", user), + "IUserWeaponSkill": projectTable("IUserWeaponSkill", user), + "IUserWeaponAwaken": projectTable("IUserWeaponAwaken", user), + "IUserPartsGroupNote": projectTable("IUserPartsGroupNote", user), + "IUserPartsPreset": projectTable("IUserPartsPreset", user), + "IUserPartsPresetTag": projectTable("IUserPartsPresetTag", user), + "IUserPartsStatusSub": projectTable("IUserPartsStatusSub", user), + "IUserNaviCutIn": projectTable("IUserNaviCutIn", user), + "IUserMovie": projectTable("IUserMovie", user), + "IUserContentsStory": projectTable("IUserContentsStory", user), + "IUserOmikuji": projectTable("IUserOmikuji", user), + "IUserDokan": projectTable("IUserDokan", user), + "IUserPortalCageStatus": projectTable("IUserPortalCageStatus", user), + "IUserEventQuestGuerrillaFreeOpen": projectTable("IUserEventQuestGuerrillaFreeOpen", user), + "IUserEventQuestProgressStatus": projectTable("IUserEventQuestProgressStatus", user), + "IUserExtraQuestProgressStatus": projectTable("IUserExtraQuestProgressStatus", user), + "IUserEventQuestDailyGroupCompleteReward": projectTable("IUserEventQuestDailyGroupCompleteReward", user), + "IUserEventQuestLabyrinthSeason": projectTable("IUserEventQuestLabyrinthSeason", user), + "IUserEventQuestLabyrinthStage": projectTable("IUserEventQuestLabyrinthStage", user), + "IUserEventQuestTowerAccumulationReward": projectTable("IUserEventQuestTowerAccumulationReward", user), + "IUserMainQuestReplayFlowStatus": projectTable("IUserMainQuestReplayFlowStatus", user), + "IUserSideStoryQuest": projectTable("IUserSideStoryQuest", user), + "IUserSideStoryQuestSceneProgressStatus": projectTable("IUserSideStoryQuestSceneProgressStatus", user), + "IUserQuestLimitContentStatus": projectTable("IUserQuestLimitContentStatus", user), + "IUserQuestReplayFlowRewardGroup": projectTable("IUserQuestReplayFlowRewardGroup", user), + "IUserQuestAutoOrbit": projectTable("IUserQuestAutoOrbit", user), + "IUserQuestSceneChoice": projectTable("IUserQuestSceneChoice", user), + "IUserQuestSceneChoiceHistory": projectTable("IUserQuestSceneChoiceHistory", user), + "IUserShopItem": projectTable("IUserShopItem", user), + "IUserShopReplaceable": projectTable("IUserShopReplaceable", user), + "IUserShopReplaceableLineup": projectTable("IUserShopReplaceableLineup", user), + "IUserExplore": projectTable("IUserExplore", user), + "IUserExploreScore": projectTable("IUserExploreScore", user), + "IUserCharacterBoard": projectTable("IUserCharacterBoard", user), + "IUserCharacterBoardAbility": projectTable("IUserCharacterBoardAbility", user), + "IUserCharacterBoardStatusUp": projectTable("IUserCharacterBoardStatusUp", user), + "IUserCharacterBoardCompleteReward": projectTable("IUserCharacterBoardCompleteReward", user), + "IUserAutoSaleSettingDetail": projectTable("IUserAutoSaleSettingDetail", user), + "IUserCharacterRebirth": projectTable("IUserCharacterRebirth", user), + "IUserCageOrnamentReward": projectTable("IUserCageOrnamentReward", user), + "IUserBigHuntProgressStatus": projectTable("IUserBigHuntProgressStatus", user), + "IUserBigHuntMaxScore": projectTable("IUserBigHuntMaxScore", user), + "IUserBigHuntStatus": projectTable("IUserBigHuntStatus", user), + "IUserBigHuntScheduleMaxScore": projectTable("IUserBigHuntScheduleMaxScore", user), + "IUserBigHuntWeeklyMaxScore": projectTable("IUserBigHuntWeeklyMaxScore", user), + "IUserBigHuntWeeklyStatus": projectTable("IUserBigHuntWeeklyStatus", user), + } +} + +func FirstEntranceClientTableMap(user store.UserState) map[string]string { + tables := FullClientTableMap(user) + for _, table := range []string{ + "IUserCharacter", + "IUserCostume", + "IUserWeapon", + "IUserCompanion", + "IUserDeckCharacter", + "IUserDeck", + "IUserTutorialProgress", + "IUserParts", + "IUserWeaponNote", + "IUserWeaponStory", + "IUserCostumeActiveSkill", + "IUserDeckTypeNote", + } { + tables[table] = "[]" + } + return tables +} + +func SelectTables(all map[string]string, requested []string) map[string]string { + selected := make(map[string]string, len(requested)) + for _, table := range requested { + if payload, ok := all[table]; ok && payload != "" { + selected[table] = payload + continue + } + selected[table] = "[]" + } + return selected +} + +func BuildDiffFromTables(tables map[string]string) map[string]*pb.DiffData { + diff := make(map[string]*pb.DiffData, len(tables)) + for table, payload := range tables { + if payload == "" { + payload = "[]" + } + diff[table] = &pb.DiffData{ + UpdateRecordsJson: payload, + DeleteKeysJson: "[]", + } + } + return diff +} + +// BuildDiffFromTablesOrdered builds a diff map with tables in the given order. +// Use when client applies tables in received order and order matters (e.g. IUserWeapon before IUserWeaponStory). +// Protobuf map serialization order is implementation-defined; this at least ensures we only include +// the requested tables in the specified sequence when building the map. +func BuildDiffFromTablesOrdered(tables map[string]string, order []string) map[string]*pb.DiffData { + diff := make(map[string]*pb.DiffData, len(order)) + for _, table := range order { + payload, ok := tables[table] + if !ok { + payload = "[]" + } + if payload == "" { + payload = "[]" + } + diff[table] = &pb.DiffData{ + UpdateRecordsJson: payload, + DeleteKeysJson: "[]", + } + } + return diff +} diff --git a/server/internal/userdata/userdata.go b/server/internal/userdata/userdata.go new file mode 100644 index 0000000..c129b18 --- /dev/null +++ b/server/internal/userdata/userdata.go @@ -0,0 +1,301 @@ +package userdata + +import ( + "encoding/base64" + "encoding/json" + "fmt" + + "lunar-tear/server/internal/gametime" + + "github.com/vmihailenco/msgpack/v5" +) + +// EntityIUser mirrors the game's EntityIUser [MessagePackObject] with [Key(0..7)]. +// Serialized as a MessagePack array of 8 elements. +type EntityIUser struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + PlayerId int64 // Key(1) + OsType int32 // Key(2) — 2 = Android + PlatformType int32 // Key(3) — 2 = GooglePlay + UserRestrictionType int32 // Key(4) — 0 = None + RegisterDatetime int64 // Key(5) — unix millis + GameStartDatetime int64 // Key(6) — unix millis + LatestVersion int64 // Key(7) +} + +// EntityIUserSetting mirrors EntityIUserSetting [Key(0..2)]. +type EntityIUserSetting struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 `json:"userId"` // Key(0) + IsNotifyPurchaseAlert bool `json:"isNotifyPurchaseAlert"` // Key(1) + LatestVersion int64 `json:"latestVersion"` // Key(2) +} + +// EntityIUserTutorialProgress mirrors EntityIUserTutorialProgress [Key(0..4)]. +type EntityIUserTutorialProgress struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + TutorialType int32 // Key(1) + ProgressPhase int32 // Key(2) + ChoiceId int32 // Key(3) + LatestVersion int64 // Key(4) +} + +// EntityIUserQuest mirrors EntityIUserQuest [Key(0..9)]. +type EntityIUserQuest struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + QuestId int32 // Key(1) + QuestStateType int32 // Key(2) — 2 = Cleared + IsBattleOnly bool // Key(3) + LatestStartDatetime int64 // Key(4) — unix millis + ClearCount int32 // Key(5) + DailyClearCount int32 // Key(6) + LastClearDatetime int64 // Key(7) — unix millis + ShortestClearFrames int32 // Key(8) + LatestVersion int64 // Key(9) +} + +// EntityIUserMainQuestFlowStatus mirrors EntityIUserMainQuestFlowStatus [Key(0..2)]. +type EntityIUserMainQuestFlowStatus struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + CurrentQuestFlowType int32 // Key(1) // QuestFlowType: 0=UNKNOWN, 1=MAIN_FLOW, 2=SUB_FLOW, 3=REPLAY_FLOW, 4=ANOTHER_ROUTE_REPLAY_FLOW + LatestVersion int64 // Key(2) +} + +// EntityIUserMainQuestMainFlowStatus mirrors EntityIUserMainQuestMainFlowStatus [Key(0..5)]. +type EntityIUserMainQuestMainFlowStatus struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + CurrentMainQuestRouteId int32 // Key(1) + CurrentQuestSceneId int32 // Key(2) + HeadQuestSceneId int32 // Key(3) + IsReachedLastQuestScene bool // Key(4) + LatestVersion int64 // Key(5) +} + +// EntityIUserMainQuestProgressStatus mirrors EntityIUserMainQuestProgressStatus [Key(0..4)]. +// This table is used by ActivePlayerToEntityPlayingMainQuestStatus (0x2AB4A48). +type EntityIUserMainQuestProgressStatus struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + CurrentQuestSceneId int32 // Key(1) + HeadQuestSceneId int32 // Key(2) + CurrentQuestFlowType int32 // Key(3) // QuestFlowType: 0=UNKNOWN, 1=MAIN_FLOW, 2=SUB_FLOW, 3=REPLAY_FLOW, 4=ANOTHER_ROUTE_REPLAY_FLOW + LatestVersion int64 // Key(4) +} + +// EntityIUserMainQuestSeasonRoute mirrors EntityIUserMainQuestSeasonRoute [Key(0..3)]. +type EntityIUserMainQuestSeasonRoute struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + MainQuestSeasonId int32 // Key(1) + MainQuestRouteId int32 // Key(2) + LatestVersion int64 // Key(3) +} + +// EntityIUserStatus mirrors EntityIUserStatus [Key(0..5)]. +type EntityIUserStatus struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + Level int32 // Key(1) + Exp int32 // Key(2) + StaminaMilliValue int32 // Key(3) + StaminaUpdateDatetime int64 // Key(4) + LatestVersion int64 // Key(5) +} + +// EntityIUserGem mirrors EntityIUserGem [Key(0..2)]. +type EntityIUserGem struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 `json:"userId"` // Key(0) + PaidGem int32 `json:"paidGem"` // Key(1) + FreeGem int32 `json:"freeGem"` // Key(2) +} + +// EntityIUserProfile mirrors EntityIUserProfile [Key(0..7)]. +type EntityIUserProfile struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + Name string // Key(1) + NameUpdateDatetime int64 // Key(2) + Message string // Key(3) + MessageUpdateDatetime int64 // Key(4) + FavoriteCostumeId int32 // Key(5) + FavoriteCostumeIdUpdateDatetime int64 // Key(6) + LatestVersion int64 // Key(7) +} + +// EntityIUserCharacter mirrors EntityIUserCharacter [Key(0..4)]. +type EntityIUserCharacter struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + CharacterId int32 // Key(1) + Level int32 // Key(2) + Exp int32 // Key(3) + LatestVersion int64 // Key(4) +} + +// EntityIUserCostume mirrors EntityIUserCostume [Key(0..9)]. +type EntityIUserCostume struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + UserCostumeUuid string // Key(1) + CostumeId int32 // Key(2) + LimitBreakCount int32 // Key(3) + Level int32 // Key(4) + Exp int32 // Key(5) + HeadupDisplayViewId int32 // Key(6) + AcquisitionDatetime int64 // Key(7) + AwakenCount int32 // Key(8) + LatestVersion int64 // Key(9) +} + +// EntityIUserWeapon mirrors EntityIUserWeapon [Key(0..8)]. +type EntityIUserWeapon struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + UserWeaponUuid string // Key(1) + WeaponId int32 // Key(2) + Level int32 // Key(3) + Exp int32 // Key(4) + LimitBreakCount int32 // Key(5) + IsProtected bool // Key(6) + AcquisitionDatetime int64 // Key(7) + LatestVersion int64 // Key(8) +} + +// EntityIUserCompanion mirrors EntityIUserCompanion [Key(0..6)]. +type EntityIUserCompanion struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + UserCompanionUuid string // Key(1) + CompanionId int32 // Key(2) + HeadupDisplayViewId int32 // Key(3) + Level int32 // Key(4) + AcquisitionDatetime int64 // Key(5) + LatestVersion int64 // Key(6) +} + +// EntityIUserDeckCharacter mirrors EntityIUserDeckCharacter [Key(0..7)]. +type EntityIUserDeckCharacter struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + UserDeckCharacterUuid string // Key(1) + UserCostumeUuid string // Key(2) + MainUserWeaponUuid string // Key(3) + UserCompanionUuid string // Key(4) + Power int32 // Key(5) + UserThoughtUuid string // Key(6) + LatestVersion int64 // Key(7) +} + +// EntityIUserDeck mirrors EntityIUserDeck [Key(0..8)]. +type EntityIUserDeck struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + DeckType int32 // Key(1) + UserDeckNumber int32 // Key(2) + UserDeckCharacterUuid01 string // Key(3) + UserDeckCharacterUuid02 string // Key(4) + UserDeckCharacterUuid03 string // Key(5) + Name string // Key(6) + Power int32 // Key(7) + LatestVersion int64 // Key(8) +} + +// EntityIUserLogin mirrors EntityIUserLogin [Key(0..6)]. +type EntityIUserLogin struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 `json:"userId"` // Key(0) + TotalLoginCount int32 `json:"totalLoginCount"` // Key(1) + ContinualLoginCount int32 `json:"continualLoginCount"` // Key(2) + MaxContinualLoginCount int32 `json:"maxContinualLoginCount"` // Key(3) + LastLoginDatetime int64 `json:"lastLoginDatetime"` // Key(4) + LastComebackLoginDatetime int64 `json:"lastComebackLoginDatetime"` // Key(5) + LatestVersion int64 `json:"latestVersion"` // Key(6) +} + +// EntityIUserLoginBonus mirrors EntityIUserLoginBonus [Key(0..5)]. +type EntityIUserLoginBonus struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 `json:"userId"` // Key(0) + LoginBonusId int32 `json:"loginBonusId"` // Key(1) + CurrentPageNumber int32 `json:"currentPageNumber"` // Key(2) + CurrentStampNumber int32 `json:"currentStampNumber"` // Key(3) + LatestRewardReceiveDatetime int64 `json:"latestRewardReceiveDatetime"` // Key(4) + LatestVersion int64 `json:"latestVersion"` // Key(5) +} + +// EntityIUserMission mirrors EntityIUserMission [Key(0..6)]. +type EntityIUserMission struct { + _msgpack struct{} `msgpack:",asArray"` + UserId int64 // Key(0) + MissionId int32 // Key(1) + StartDatetime int64 // Key(2) + ProgressValue int32 // Key(3) + MissionProgressStatusType int32 // Key(4) + ClearDatetime int64 // Key(5) + LatestVersion int64 // Key(6) +} + +// EncodeRecords serializes a slice of entities to the client-expected format: +// a JSON array of base64-encoded MessagePack byte strings. +func EncodeRecords(entities ...any) (string, error) { + b64List := make([]string, 0, len(entities)) + for _, e := range entities { + data, err := msgpack.Marshal(e) + if err != nil { + return "", fmt.Errorf("msgpack marshal: %w", err) + } + b64List = append(b64List, base64.StdEncoding.EncodeToString(data)) + } + jsonBytes, err := json.Marshal(b64List) + if err != nil { + return "", fmt.Errorf("json marshal: %w", err) + } + return string(jsonBytes), nil +} + +func encodeJSONRecords(entities ...any) (string, error) { + jsonBytes, err := json.Marshal(entities) + if err != nil { + return "", fmt.Errorf("json marshal records: %w", err) + } + return string(jsonBytes), nil +} + +func encodeJSONMaps(records ...map[string]any) (string, error) { + jsonBytes, err := json.Marshal(records) + if err != nil { + return "", fmt.Errorf("json marshal maps: %w", err) + } + return string(jsonBytes), nil +} + +// DefaultUserData returns pre-built user data tables for a fresh user. +// We provide BOTH msgpack-encoded (base64) and plain JSON variants. +// The server tries msgpack first; if the client doesn't accept it, switch to JSON. +func DefaultUserData(userId int64) map[string]string { + now := gametime.Now().Unix() + + userRecord, _ := EncodeRecords(&EntityIUser{ + UserId: userId, + PlayerId: userId, + OsType: 2, + PlatformType: 2, + RegisterDatetime: now, + }) + + settingRecord, _ := EncodeRecords(&EntityIUserSetting{ + UserId: userId, + }) + + data := map[string]string{ + "user": userRecord, + "user_setting": settingRecord, + } + return data +} diff --git a/server/internal/utils/utils.go b/server/internal/utils/utils.go new file mode 100644 index 0000000..889f04d --- /dev/null +++ b/server/internal/utils/utils.go @@ -0,0 +1,21 @@ +package utils + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +func ReadJSON[T any](filename string) ([]T, error) { + path := filepath.Join("assets", "master_data", filename) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + var out []T + if err := json.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("unmarshal %s: %w", path, err) + } + return out, nil +} diff --git a/server/proto/banner.proto b/server/proto/banner.proto new file mode 100644 index 0000000..ee1bee5 --- /dev/null +++ b/server/proto/banner.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/mission.proto"; +import "proto/data.proto"; + +package apb.api.banner; + +service BannerService { + rpc GetMamaBanner (GetMamaBannerRequest) returns (GetMamaBannerResponse); +} + +message GetMamaBannerRequest { + apb.api.mission.CageMeasurableValues cageMeasurableValues = 50; +} + +message GetMamaBannerResponse { + repeated GachaBanner termLimitedGacha = 2; + GachaBanner latestChapterGacha = 3; + bool isExistUnreadPop = 4; + map diffUserData = 99; +} + +message GachaBanner { + int32 gachaLabelType = 1; + string gachaAssetName = 2; + int32 gachaId = 3; +} \ No newline at end of file diff --git a/server/proto/battle.proto b/server/proto/battle.proto new file mode 100644 index 0000000..ebe4bdc --- /dev/null +++ b/server/proto/battle.proto @@ -0,0 +1,138 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.battle; + +service BattleService { + rpc StartWave (StartWaveRequest) returns (StartWaveResponse); + rpc FinishWave (FinishWaveRequest) returns (FinishWaveResponse); +} + +message BattleReportRandomDisplay { + int32 randomDisplayValueType = 1; + int64 randomDisplayValue = 2; +} + +message CostumeBattleInfo { + int32 deckCharacterNumber = 1; + int64 totalDamage = 2; + bool isAlive = 4; + int32 hitCount = 5; + int64 maxHp = 6; + int64 remainingHp = 7; + int32 userDeckNumber = 30; + BattleReportRandomDisplay battleReportRandomDisplay = 50; +} + +message StartWaveRequest { + repeated UserPartyInitialInfo userPartyInitialInfoList = 1; + repeated NpcPartyInitialInfo npcPartyInitialInfoList = 2; +} + +message UserPartyInitialInfo { + int64 userId = 1; + int32 deckType = 2; + int64 userDeckNumber = 3; + int64 totalHp = 4; + string vt = 200; +} + +message NpcPartyInitialInfo { + int64 npcId = 1; + int32 deckType = 2; + int32 battleNpcDeckNumber = 3; + int64 totalHp = 4; +} + +message StartWaveResponse { + map diffUserData = 99; +} + +message FinishWaveRequest { + bytes battleBinary = 1; + BattleDetail battleDetail = 2; + repeated UserPartyResultInfo userPartyResultInfoList = 3; + repeated NpcPartyResultInfo npcPartyResultInfoList = 4; + int64 elapsedFrameCount = 5; + string vt = 200; +} + +message BattleDetail { + int32 characterDeathCount = 1; + int32 maxDamage = 2; + int32 playerCostumeActiveSkillUsedCount = 3; + int32 playerWeaponActiveSkillUsedCount = 4; + int32 playerCompanionSkillUsedCount = 5; + int32 criticalCount = 6; + int32 comboCount = 7; + int32 comboMaxDamage = 8; + repeated CostumeBattleInfo costumeBattleInfo = 9; + int64 totalRecoverPoint = 10; +} + +message UserPartyResultInfo { + int64 userId = 1; + int32 deckType = 2; + int32 userDeckNumber = 3; + repeated AddUserDamageInfo addDamageInfoList = 4; + repeated UserRecoverInfo userRecoverInfo = 5; + repeated SkillUseInfo skillUseInfo = 6; + int32 characterDeathCount = 7; + int32 characterReviveCount = 8; + int32 characterHpDepletedCount = 9; +} + +message AddUserDamageInfo { + int64 userId = 1; + int32 deckType = 2; + int32 deckNumber = 3; + int64 totalDamage = 4; + int64 totalUnclampedDamage = 5; +} + +message UserRecoverInfo { + int64 userId = 1; + int32 deckType = 2; + int32 deckNumber = 3; + int64 totalRecoverPoint = 4; +} + +message SkillUseInfo { + string deckCharacterUuid = 1; + int32 skillDetailId = 2; + int32 useCount = 3; +} + +message NpcPartyResultInfo { + int64 npcId = 1; + int32 deckType = 2; + int32 battleNpcDeckNumber = 3; + repeated AddNpcDamageInfo addDamageInfoList = 4; + repeated NpcRecoverInfo npcRecoverInfo = 5; + repeated SkillUseInfo skillUseInfo = 6; + int32 characterDeathCount = 7; + int32 characterReviveCount = 8; + int32 characterHpDepletedCount = 9; +} + +message AddNpcDamageInfo { + int64 npcId = 1; + int32 deckType = 2; + int32 deckNumber = 3; + int64 totalDamage = 4; + int64 totalUnclampedDamage = 5; +} + +message NpcRecoverInfo { + int64 npcId = 1; + int32 deckType = 2; + int32 deckNumber = 3; + int64 totalRecoverPoint = 4; +} + +message FinishWaveResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/bighunt.proto b/server/proto/bighunt.proto new file mode 100644 index 0000000..666704e --- /dev/null +++ b/server/proto/bighunt.proto @@ -0,0 +1,142 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/data.proto"; +import "proto/battle.proto"; + +package apb.api.bighunt; + +service BigHuntService { + rpc StartBigHuntQuest (StartBigHuntQuestRequest) returns (StartBigHuntQuestResponse); + rpc UpdateBigHuntQuestSceneProgress (UpdateBigHuntQuestSceneProgressRequest) returns (UpdateBigHuntQuestSceneProgressResponse); + rpc FinishBigHuntQuest (FinishBigHuntQuestRequest) returns (FinishBigHuntQuestResponse); + rpc RestartBigHuntQuest (RestartBigHuntQuestRequest) returns (RestartBigHuntQuestResponse); + rpc SkipBigHuntQuest (SkipBigHuntQuestRequest) returns (SkipBigHuntQuestResponse); + rpc SaveBigHuntBattleInfo (SaveBigHuntBattleInfoRequest) returns (SaveBigHuntBattleInfoResponse); + rpc GetBigHuntTopData (google.protobuf.Empty) returns (GetBigHuntTopDataResponse); +} + +message WeeklyScoreResult { + int32 attributeType = 1; + int64 beforeMaxScore = 2; + int64 currentMaxScore = 3; + int32 beforeAssetGradeIconId = 4; + int32 currentAssetGradeIconId = 5; + int64 afterMaxScore = 6; + int32 afterAssetGradeIconId = 7; +} + +message BigHuntReward { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; +} + +message StartBigHuntQuestRequest { + int32 bigHuntBossQuestId = 1; + int32 bigHuntQuestId = 2; + int32 userDeckNumber = 3; + bool isDryRun = 4; +} + +message StartBigHuntQuestResponse { + map diffUserData = 99; +} + +message UpdateBigHuntQuestSceneProgressRequest { + int32 questSceneId = 1; +} + +message UpdateBigHuntQuestSceneProgressResponse { + map diffUserData = 99; +} + +message FinishBigHuntQuestRequest { + int32 bigHuntBossQuestId = 1; + int32 bigHuntQuestId = 2; + bool isRetired = 3; + string vt = 200; +} + +message FinishBigHuntQuestResponse { + BigHuntScoreInfo scoreInfo = 1; + repeated BigHuntReward scoreReward = 2; + BigHuntBattleReport battleReport = 3; + map diffUserData = 99; +} + +message BigHuntScoreInfo { + int64 userScore = 1; + bool isHighScore = 2; + int64 totalDamage = 3; + int64 baseScore = 4; + int32 difficultyBonusPermil = 5; + int32 aliveBonusPermil = 6; + int32 maxComboBonusPermil = 7; + int32 assetGradeIconId = 8; +} + +message BigHuntBattleReport { + repeated BigHuntBattleReportWave battleReportWave = 1; +} + +message BigHuntBattleReportWave { + repeated BigHuntBattleReportCostume battleReportCostume = 1; +} + +message BigHuntBattleReportCostume { + int32 costumeId = 1; + int64 totalDamage = 2; + int32 hitCount = 3; + apb.api.battle.BattleReportRandomDisplay battleReportRandomDisplay = 4; +} + +message RestartBigHuntQuestRequest { + int32 bigHuntBossQuestId = 1; + int32 bigHuntQuestId = 2; +} + +message RestartBigHuntQuestResponse { + bytes battleBinary = 1; + int32 deckNumber = 2; + map diffUserData = 99; +} + +message SkipBigHuntQuestRequest { + int32 bigHuntBossQuestId = 1; + int32 skipCount = 2; +} + +message SkipBigHuntQuestResponse { + repeated BigHuntReward scoreReward = 1; + map diffUserData = 99; +} + +message SaveBigHuntBattleInfoRequest { + bytes battleBinary = 1; + BigHuntBattleDetail bigHuntBattleDetail = 2; + int64 elapsedFrameCount = 3; + string vt = 200; +} + +message BigHuntBattleDetail { + int32 deckType = 1; + int32 userTripleDeckNumber = 2; + int32 bossKnockDownCount = 3; + int32 maxComboCount = 4; + repeated apb.api.battle.CostumeBattleInfo costumeBattleInfo = 9; +} + +message SaveBigHuntBattleInfoResponse { + map diffUserData = 99; +} + +message GetBigHuntTopDataResponse { + repeated WeeklyScoreResult weeklyScoreResult = 1; + repeated BigHuntReward weeklyScoreReward = 2; + bool isReceivedWeeklyScoreReward = 3; + repeated BigHuntReward lastWeekWeeklyScoreReward = 4; + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/cageornament.proto b/server/proto/cageornament.proto new file mode 100644 index 0000000..61402a3 --- /dev/null +++ b/server/proto/cageornament.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.cageornament; + +service CageOrnamentService { + rpc ReceiveReward (ReceiveRewardRequest) returns (ReceiveRewardResponse); + rpc RecordAccess (RecordAccessRequest) returns (RecordAccessResponse); +} + +message ReceiveRewardRequest { + int32 cageOrnamentId = 1; +} + +message ReceiveRewardResponse { + repeated CageOrnamentReward cageOrnamentReward = 1; + map diffUserData = 99; +} + +message CageOrnamentReward { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; +} + +message RecordAccessRequest { + int32 cageOrnamentId = 1; +} + +message RecordAccessResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/character.proto b/server/proto/character.proto new file mode 100644 index 0000000..48acb90 --- /dev/null +++ b/server/proto/character.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.character; + +service CharacterService { + rpc Rebirth (RebirthRequest) returns (RebirthResponse); +} + +message RebirthRequest { + int32 characterId = 1; + int32 rebirthCount = 2; +} + +message RebirthResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/characterboard.proto b/server/proto/characterboard.proto new file mode 100644 index 0000000..b12863f --- /dev/null +++ b/server/proto/characterboard.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.characterboard; + +service CharacterBoardService { + rpc ReleasePanel (ReleasePanelRequest) returns (ReleasePanelResponse); +} + +message ReleasePanelRequest { + repeated int32 characterBoardPanelId = 1; +} + +message ReleasePanelResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/characterviewer.proto b/server/proto/characterviewer.proto new file mode 100644 index 0000000..6ca58a6 --- /dev/null +++ b/server/proto/characterviewer.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/data.proto"; + +package apb.api.characterviewer; + +service CharacterViewerService { + rpc CharacterViewerTop (google.protobuf.Empty) returns (CharacterViewerTopResponse); +} + +message CharacterViewerTopResponse { + repeated int32 releaseCharacterViewerFieldId = 1; + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/companion.proto b/server/proto/companion.proto new file mode 100644 index 0000000..ee9b75f --- /dev/null +++ b/server/proto/companion.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.companion; + +service CompanionService { + rpc Enhance (CompanionEnhanceRequest) returns (CompanionEnhanceResponse); +} + +message CompanionEnhanceRequest { + string userCompanionUuid = 1; + int32 addLevelCount = 2; +} + +message CompanionEnhanceResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/config.proto b/server/proto/config.proto new file mode 100644 index 0000000..aea32bb --- /dev/null +++ b/server/proto/config.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/data.proto"; + +package apb.api.config; + +service ConfigService { + rpc GetReviewServerConfig (google.protobuf.Empty) returns (GetReviewServerConfigResponse); +} + +message GetReviewServerConfigResponse { + ApiConfig api = 1; + OctoConfig octo = 2; + WebViewConfig webView = 3; + MasterDataConfig masterData = 4; + map diffUserData = 99; +} + +message ApiConfig { + string hostname = 1; + int32 port = 2; +} + +message OctoConfig { + int32 version = 1; + int32 appId = 2; + string clientSecretKey = 3; + string aesKey = 4; + string url = 5; +} + +message WebViewConfig { + string baseUrl = 1; +} + +message MasterDataConfig { + string urlFormat = 1; +} \ No newline at end of file diff --git a/server/proto/consumableitem.proto b/server/proto/consumableitem.proto new file mode 100644 index 0000000..9defa42 --- /dev/null +++ b/server/proto/consumableitem.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.consumableitem; + +service ConsumableitemService { + rpc UseEffectItem (UseEffectItemRequest) returns (UseEffectItemResponse); + rpc Sell (SellRequest) returns (SellResponse); +} + +message UseEffectItemRequest { + int32 consumableItemId = 1; + int32 count = 2; +} + +message UseEffectItemResponse { + map diffUserData = 99; +} + +message SellRequest { + repeated SellPossession consumableItemPossession = 1; +} + +message SellPossession { + int32 consumableItemId = 1; + int32 count = 2; +} + +message SellResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/contentsstory.proto b/server/proto/contentsstory.proto new file mode 100644 index 0000000..884a278 --- /dev/null +++ b/server/proto/contentsstory.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.contentsstory; + +service ContentsStoryService { + rpc RegisterPlayed (ContentsStoryRegisterPlayedRequest) returns (ContentsStoryRegisterPlayedResponse); +} + +message ContentsStoryRegisterPlayedRequest { + int32 contentsStoryId = 1; +} + +message ContentsStoryRegisterPlayedResponse { + map diffUserData = 99; +} diff --git a/server/proto/costume.proto b/server/proto/costume.proto new file mode 100644 index 0000000..986de64 --- /dev/null +++ b/server/proto/costume.proto @@ -0,0 +1,92 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.costume; + +service CostumeService { + rpc Enhance (EnhanceRequest) returns (EnhanceResponse); + rpc LimitBreak (LimitBreakRequest) returns (LimitBreakResponse); + rpc Awaken (AwakenRequest) returns (AwakenResponse); + rpc EnhanceActiveSkill (EnhanceActiveSkillRequest) returns (EnhanceActiveSkillResponse); + rpc RegisterLevelBonusConfirmed (RegisterLevelBonusConfirmedRequest) returns (RegisterLevelBonusConfirmedResponse); + rpc UnlockLotteryEffectSlot (UnlockLotteryEffectSlotRequest) returns (UnlockLotteryEffectSlotResponse); + rpc DrawLotteryEffect (DrawLotteryEffectRequest) returns (DrawLotteryEffectResponse); + rpc ConfirmLotteryEffect (ConfirmLotteryEffectRequest) returns (ConfirmLotteryEffectResponse); +} + +message EnhanceRequest { + string userCostumeUuid = 1; + map materials = 2; +} + +message EnhanceResponse { + bool isGreatSuccess = 1; + map surplusEnhanceMaterial = 2; + map diffUserData = 99; +} + +message LimitBreakRequest { + string userCostumeUuid = 1; + map materials = 2; +} + +message LimitBreakResponse { + map diffUserData = 99; +} + +message AwakenRequest { + string userCostumeUuid = 1; + map materials = 2; +} + +message AwakenResponse { + map diffUserData = 99; +} + +message EnhanceActiveSkillRequest { + string userCostumeUuid = 1; + int32 addLevelCount = 2; +} + +message EnhanceActiveSkillResponse { + map diffUserData = 99; +} + +message RegisterLevelBonusConfirmedRequest { + int32 costumeId = 1; + int32 level = 2; +} + +message RegisterLevelBonusConfirmedResponse { + map diffUserData = 99; +} + +message UnlockLotteryEffectSlotRequest { + string userCostumeUuid = 1; + int32 slotNumber = 2; +} + +message UnlockLotteryEffectSlotResponse { + map diffUserData = 99; +} + +message DrawLotteryEffectRequest { + string userCostumeUuid = 1; + int32 slotNumber = 2; +} + +message DrawLotteryEffectResponse { + map diffUserData = 99; +} + +message ConfirmLotteryEffectRequest { + string userCostumeUuid = 1; + bool isAccept = 2; +} + +message ConfirmLotteryEffectResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/data.proto b/server/proto/data.proto new file mode 100644 index 0000000..093737e --- /dev/null +++ b/server/proto/data.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; + +package apb.api.data; + +service DataService { + rpc GetLatestMasterDataVersion (google.protobuf.Empty) returns (MasterDataGetLatestVersionResponse); + rpc GetUserDataNameV2 (google.protobuf.Empty) returns (UserDataGetNameResponseV2); + rpc GetUserData (UserDataGetRequest) returns (UserDataGetResponse); +} + +message DiffData { + string updateRecordsJson = 1; + string deleteKeysJson = 2; +} + +message MasterDataGetLatestVersionResponse { + string latestMasterDataVersion = 1; +} + +message UserDataGetNameResponseV2 { + repeated TableNameList tableNameList = 1; +} + +message TableNameList { + repeated string tableName = 1; +} + +message UserDataGetRequest { + repeated string tableName = 1; +} + +message UserDataGetResponse { + map userDataJson = 1; +} \ No newline at end of file diff --git a/server/proto/deck.proto b/server/proto/deck.proto new file mode 100644 index 0000000..3057b3a --- /dev/null +++ b/server/proto/deck.proto @@ -0,0 +1,158 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.deck; + +service DeckService { + rpc UpdateName (UpdateNameRequest) returns (UpdateNameResponse); + rpc ReplaceDeck (ReplaceDeckRequest) returns (ReplaceDeckResponse); + rpc SetPvpDefenseDeck (SetPvpDefenseDeckRequest) returns (SetPvpDefenseDeckResponse); + rpc CopyDeck (CopyDeckRequest) returns (CopyDeckResponse); + rpc RemoveDeck (RemoveDeckRequest) returns (RemoveDeckResponse); + rpc RefreshDeckPower (RefreshDeckPowerRequest) returns (RefreshDeckPowerResponse); + rpc UpdateTripleDeckName (UpdateTripleDeckNameRequest) returns (UpdateTripleDeckNameResponse); + rpc ReplaceTripleDeck (ReplaceTripleDeckRequest) returns (ReplaceTripleDeckResponse); + rpc ReplaceMultiDeck (ReplaceMultiDeckRequest) returns (ReplaceMultiDeckResponse); + rpc RefreshMultiDeckPower (RefreshMultiDeckPowerRequest) returns (RefreshMultiDeckPowerResponse); +} + +message Deck { + DeckCharacter character01 = 1; + DeckCharacter character02 = 2; + DeckCharacter character03 = 3; +} + +message DeckCharacter { + string userCostumeUuid = 1; + string mainUserWeaponUuid = 2; + repeated string subUserWeaponUuid = 3; + string userCompanionUuid = 4; + repeated string userPartsUuid = 5; + int32 dressupCostumeId = 6; + string userThoughtUuid = 7; +} + +message UpdateNameRequest { + int32 deckType = 1; + int32 userDeckNumber = 2; + string name = 3; +} + +message UpdateNameResponse { + map diffUserData = 99; +} + +message ReplaceDeckRequest { + int32 deckType = 1; + int32 userDeckNumber = 2; + Deck deck = 3; +} + +message ReplaceDeckResponse { + map diffUserData = 99; +} + +message SetPvpDefenseDeckRequest { + int32 userDeckNumber = 1; + DeckPower deckPower = 2; +} + +message DeckPower { + int32 power = 1; + DeckCharacterPower deckCharacterPower01 = 2; + DeckCharacterPower deckCharacterPower02 = 3; + DeckCharacterPower deckCharacterPower03 = 4; +} + +message DeckCharacterPower { + string userDeckCharacterUuid = 1; + int32 power = 2; +} + +message SetPvpDefenseDeckResponse { + map diffUserData = 99; +} + +message CopyDeckRequest { + int32 fromDeckType = 1; + int32 fromUserDeckNumber = 2; + int32 toDeckType = 3; + int32 toUserDeckNumber = 4; +} + +message CopyDeckResponse { + int32 resultType = 1; + map diffUserData = 99; +} + +message RemoveDeckRequest { + int32 deckType = 1; + int32 userDeckNumber = 2; +} + +message RemoveDeckResponse { + map diffUserData = 99; +} + +message RefreshDeckPowerRequest { + int32 deckType = 1; + int32 userDeckNumber = 2; + DeckPower deckPower = 3; +} + +message RefreshDeckPowerResponse { + map diffUserData = 99; +} + +message UpdateTripleDeckNameRequest { + int32 deckType = 1; + int32 userDeckNumber = 2; + string name = 3; +} + +message UpdateTripleDeckNameResponse { + map diffUserData = 99; +} + +message ReplaceTripleDeckRequest { + int32 deckType = 1; + int32 userDeckNumber = 2; + DeckDetail deckDetail01 = 3; + DeckDetail deckDetail02 = 4; + DeckDetail deckDetail03 = 5; +} + +message DeckDetail { + int32 deckType = 1; + int32 userDeckNumber = 2; + Deck deck = 3; +} + +message ReplaceTripleDeckResponse { + map diffUserData = 99; +} + +message ReplaceMultiDeckRequest { + repeated DeckDetail deckDetail = 1; +} + +message ReplaceMultiDeckResponse { + map diffUserData = 99; +} + +message RefreshMultiDeckPowerRequest { + repeated DeckPowerInfo deckPowerInfo = 1; +} + +message DeckPowerInfo { + int32 deckType = 1; + int32 userDeckNumber = 2; + DeckPower deckPower = 3; +} + +message RefreshMultiDeckPowerResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/dokan.proto b/server/proto/dokan.proto new file mode 100644 index 0000000..4678053 --- /dev/null +++ b/server/proto/dokan.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.dokan; + +service DokanService { + rpc RegisterDokanConfirmed (RegisterDokanConfirmedRequest) returns (RegisterDokanConfirmedResponse); +} + +message RegisterDokanConfirmedRequest { + repeated int32 dokanId = 1; +} + +message RegisterDokanConfirmedResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/explore.proto b/server/proto/explore.proto new file mode 100644 index 0000000..8727949 --- /dev/null +++ b/server/proto/explore.proto @@ -0,0 +1,49 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.explore; + +service ExploreService { + rpc StartExplore (StartExploreRequest) returns (StartExploreResponse); + rpc FinishExplore (FinishExploreRequest) returns (FinishExploreResponse); + rpc RetireExplore (RetireExploreRequest) returns (RetireExploreResponse); +} + +message StartExploreRequest { + int32 exploreId = 1; + int32 useConsumableItemId = 2; +} + +message StartExploreResponse { + map diffUserData = 99; +} + +message FinishExploreRequest { + int32 exploreId = 1; + int32 score = 2; + string vt = 200; +} + +message FinishExploreResponse { + int32 acquireStaminaCount = 1; + repeated ExploreReward exploreReward = 2; + int32 assetGradeIconId = 3; + map diffUserData = 99; +} + +message ExploreReward { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; +} + +message RetireExploreRequest { + int32 exploreId = 1; +} + +message RetireExploreResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/friend.proto b/server/proto/friend.proto new file mode 100644 index 0000000..dff39db --- /dev/null +++ b/server/proto/friend.proto @@ -0,0 +1,135 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/data.proto"; +import "google/protobuf/timestamp.proto"; +import "proto/mission.proto"; + +package apb.api.friend; + +service FriendService { + rpc GetUser (GetUserRequest) returns (GetUserResponse); + rpc SearchRecommendedUsers (google.protobuf.Empty) returns (SearchRecommendedUsersResponse); + rpc GetFriendList (GetFriendListRequest) returns (GetFriendListResponse); + rpc GetFriendRequestList (google.protobuf.Empty) returns (GetFriendRequestListResponse); + rpc SendFriendRequest (SendFriendRequestRequest) returns (SendFriendRequestResponse); + rpc AcceptFriendRequest (AcceptFriendRequestRequest) returns (AcceptFriendRequestResponse); + rpc DeclineFriendRequest (DeclineFriendRequestRequest) returns (DeclineFriendRequestResponse); + rpc DeleteFriend (DeleteFriendRequest) returns (DeleteFriendResponse); + rpc CheerFriend (CheerFriendRequest) returns (CheerFriendResponse); + rpc BulkCheerFriend (google.protobuf.Empty) returns (BulkCheerFriendResponse); + rpc ReceiveCheer (ReceiveCheerRequest) returns (ReceiveCheerResponse); + rpc BulkReceiveCheer (google.protobuf.Empty) returns (BulkReceiveCheerResponse); +} + +message GetUserRequest { + int64 playerId = 1; +} + +message GetUserResponse { + User user = 1; + map diffUserData = 99; +} + +message User { + int64 playerId = 1; + string userName = 2; + google.protobuf.Timestamp lastLoginDatetime = 3; + int32 maxDeckPower = 4; + int32 favoriteCostumeId = 5; + int32 level = 6; +} + +message SearchRecommendedUsersResponse { + repeated User users = 1; + map diffUserData = 99; +} + +message GetFriendListRequest { + apb.api.mission.CageMeasurableValues cageMeasurableValues = 50; +} + +message GetFriendListResponse { + repeated FriendUser friendUser = 1; + int32 sendCheerCount = 2; + int32 receivedCheerCount = 3; + map diffUserData = 99; +} + +message FriendUser { + int64 playerId = 1; + string userName = 2; + google.protobuf.Timestamp lastLoginDatetime = 3; + int32 maxDeckPower = 4; + int32 favoriteCostumeId = 5; + int32 level = 6; + bool cheerReceived = 7; + bool cheerSent = 8; + bool staminaReceived = 9; +} + +message GetFriendRequestListResponse { + repeated User user = 1; + map diffUserData = 99; +} + +message SendFriendRequestRequest { + int64 playerId = 1; +} + +message SendFriendRequestResponse { + map diffUserData = 99; +} + +message AcceptFriendRequestRequest { + int64 playerId = 1; +} + +message AcceptFriendRequestResponse { + map diffUserData = 99; +} + +message DeclineFriendRequestRequest { + int64 playerIdOld = 1; + repeated int64 playerId = 2; +} + +message DeclineFriendRequestResponse { + map diffUserData = 99; +} + +message DeleteFriendRequest { + int64 playerId = 1; +} + +message DeleteFriendResponse { + map diffUserData = 99; +} + +message CheerFriendRequest { + int64 playerId = 1; +} + +message CheerFriendResponse { + map diffUserData = 99; +} + +message BulkCheerFriendResponse { + repeated int64 playerId = 1; + map diffUserData = 99; +} + +message ReceiveCheerRequest { + int64 playerId = 1; +} + +message ReceiveCheerResponse { + map diffUserData = 99; +} + +message BulkReceiveCheerResponse { + repeated int64 playerId = 1; + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/gacha.proto b/server/proto/gacha.proto new file mode 100644 index 0000000..6048d82 --- /dev/null +++ b/server/proto/gacha.proto @@ -0,0 +1,218 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; +import "proto/data.proto"; + +package apb.api.gacha; + +service GachaService { + rpc GetGachaList (GetGachaListRequest) returns (GetGachaListResponse); + rpc GetGacha (GetGachaRequest) returns (GetGachaResponse); + rpc Draw (DrawRequest) returns (DrawResponse); + rpc ResetBoxGacha (ResetBoxGachaRequest) returns (ResetBoxGachaResponse); + rpc GetRewardGacha (google.protobuf.Empty) returns (GetRewardGachaResponse); + rpc RewardDraw (RewardDrawRequest) returns (RewardDrawResponse); +} + +message MenuGachaBadgeInfo { + google.protobuf.Timestamp displayStartDatetime = 1; + google.protobuf.Timestamp displayEndDatetime = 2; +} + +message GetGachaListRequest { + repeated int32 gachaLabelType = 1; +} + +message GetGachaListResponse { + repeated Gacha gacha = 1; + ConvertedGachaMedal convertedGachaMedal = 2; + map diffUserData = 99; +} + +message Gacha { + int32 gachaId = 1; + int32 gachaLabelType = 2; + int32 gachaModeType = 4; + int32 gachaAutoResetType = 5; + int32 gachaAutoResetPeriod = 6; + google.protobuf.Timestamp nextAutoResetDatetime = 7; + repeated GachaUnlockCondition gachaUnlockCondition = 8; + bool isUserGachaUnlock = 9; + repeated GachaPricePhase gachaPricePhase = 10; + google.protobuf.Timestamp startDatetime = 13; + google.protobuf.Timestamp endDatetime = 14; + oneof gacha_mode { + GachaModeBasic gachaModeBasic = 16; + GachaModeStepupComposition gachaModeStepupComposition = 17; + GachaModeBoxComposition gachaModeBoxComposition = 18; + } + int32 relatedMainQuestChapterId = 19; + int32 promotionMovieAssetId = 20; + int32 relatedEventQuestChapterId = 21; + int32 gachaMedalId = 22; + int32 gachaDecorationType = 23; + int32 sortOrder = 24; + bool isInactive = 25; + int32 informationId = 26; +} + +message GachaModeBasic { + string naviCharacterCommentAssetName = 1; + string gachaAssetName = 2; + repeated GachaOddsItem promotionGachaOddsItem = 3; +} + +message GachaOddsItem { + GachaItem gachaItem = 1; + GachaItem gachaItemBonus = 2; + int32 maxDrawableCount = 3; + int32 drewCount = 4; + bool isTarget = 5; +} + +message GachaModeStepupComposition { + int32 gachaStepGroupId = 1; + int32 stepNumber = 2; + int32 currentStepNumber = 3; + string naviCharacterCommentAssetName = 4; + string gachaAssetName = 5; + repeated GachaOddsItem promotionGachaOddsItem = 6; + int32 currentLoopCount = 7; +} + +message GachaModeBoxComposition { + int32 gachaBoxGroupId = 1; + int32 boxNumber = 2; + int32 currentBoxNumber = 3; + bool isCurrentBoxResettable = 4; + string naviCharacterCommentAssetName = 5; + string gachaAssetName = 6; + repeated GachaOddsItem promotionGachaOddsItem = 7; + int32 gachaPricePhaseId = 8; + bool isAlreadyDrawn = 9; + bool isResettableByDrawingAllTargets = 10; + bool isInvalidReset = 11; + bool isHideMultipleDrawButton = 12; + int32 gachaDescriptionTextId = 13; +} + +message GachaUnlockCondition { + int32 gachaUnlockConditionType = 1; + int32 conditionValue = 2; +} + +message GachaPricePhase { + int32 gachaPricePhaseId = 1; + google.protobuf.Timestamp endDatetime = 2; + int32 limitExecCount = 3; + int32 userExecCount = 4; + int32 priceType = 5; + int32 priceId = 6; + int32 price = 7; + int32 regularPrice = 8; + int32 drawCount = 9; + int32 eachMaxExecCount = 10; + repeated GachaBonus gachaBonus = 11; + GachaOddsFixedRarity gachaOddsFixedRarity = 12; + bool isEnabled = 13; + int32 gachaBadgeType = 14; +} + +message GachaBonus { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; +} + +message GachaOddsFixedRarity { + int32 fixedRarityTypeLowerLimit = 1; + int32 fixedCount = 2; +} + +message ConvertedGachaMedal { + repeated ConsumableItemPossession convertedMedalPossession = 1; + ConsumableItemPossession obtainPossession = 2; +} + +message ConsumableItemPossession { + int32 consumableItemId = 1; + int32 count = 2; +} + +message GetGachaRequest { + repeated int32 gachaId = 1; +} + +message GetGachaResponse { + map gacha = 1; + map diffUserData = 99; +} + +message DrawRequest { + int32 gachaId = 1; + int32 gachaPricePhaseId = 2; + int32 execCount = 3; + string consumeUserWeaponUuid = 4; +} + +message DrawResponse { + Gacha nextGacha = 1; + repeated DrawGachaOddsItem gachaResult = 2; + repeated GachaBonus gachaBonus = 3; + repeated MenuGachaBadgeInfo menuGachaBadgeInfo = 4; + map diffUserData = 99; +} + +message DrawGachaOddsItem { + GachaItem gachaItem = 1; + GachaItem gachaItemBonus = 2; + int32 duplicationBonusGrade = 3; + repeated GachaBonus duplicationBonus = 4; + GachaBonus medalBonus = 5; + bool isTarget = 6; +} + +message GachaItem { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; + int32 promotionOrder = 4; + bool isNew = 5; +} + +message ResetBoxGachaRequest { + int32 gachaId = 1; +} + +message ResetBoxGachaResponse { + Gacha gacha = 1; + map diffUserData = 99; +} + +message GetRewardGachaResponse { + bool available = 1; + int32 todaysCurrentDrawCount = 2; + int32 dailyMaxCount = 3; + map diffUserData = 99; +} + +message RewardDrawRequest { + string placementName = 1; + string rewardName = 2; + string rewardAmount = 3; +} + +message RewardDrawResponse { + repeated RewardGachaItem rewardGachaResult = 1; + map diffUserData = 99; +} + +message RewardGachaItem { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; + bool isNew = 4; +} \ No newline at end of file diff --git a/server/proto/gameplay.proto b/server/proto/gameplay.proto new file mode 100644 index 0000000..41a26ad --- /dev/null +++ b/server/proto/gameplay.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/gacha.proto"; +import "proto/data.proto"; + +package apb.api.gameplay; + +service GamePlayService { + rpc CheckBeforeGamePlay (CheckBeforeGamePlayRequest) returns (CheckBeforeGamePlayResponse); +} + +message CheckBeforeGamePlayRequest { + string tr = 1; + int32 voiceClientSystemLanguageTypeId = 2; + int32 textClientSystemLanguageTypeId = 3; +} + +message CheckBeforeGamePlayResponse { + bool isExistUnreadPop = 1; + repeated apb.api.gacha.MenuGachaBadgeInfo menuGachaBadgeInfo = 2; + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/gift.proto b/server/proto/gift.proto new file mode 100644 index 0000000..d153b92 --- /dev/null +++ b/server/proto/gift.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/data.proto"; +import "google/protobuf/timestamp.proto"; + +package apb.api.gift; + +service GiftService { + rpc ReceiveGift (ReceiveGiftRequest) returns (ReceiveGiftResponse); + rpc GetGiftList (GetGiftListRequest) returns (GetGiftListResponse); + rpc GetGiftReceiveHistoryList (google.protobuf.Empty) returns (GetGiftReceiveHistoryListResponse); +} + +message ReceiveGiftRequest { + repeated string userGiftUuid = 1; +} + +message ReceiveGiftResponse { + repeated string receivedGiftUuid = 1; + repeated string expiredGiftUuid = 2; + repeated string overflowGiftUuid = 3; + map diffUserData = 99; +} + +message GetGiftListRequest { + repeated int32 rewardKindType = 1; + int32 expirationType = 2; + bool isAscendingSort = 3; + int64 nextCursor = 4; + int64 previousCursor = 5; + int32 getCount = 6; +} + +message GetGiftListResponse { + repeated NotReceivedGift gift = 1; + int32 totalPageCount = 2; + int64 nextCursor = 3; + int64 previousCursor = 4; + map diffUserData = 99; +} + +message NotReceivedGift { + GiftCommon giftCommon = 1; + google.protobuf.Timestamp expirationDatetime = 2; + string userGiftUuid = 3; +} + +message GiftCommon { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; + google.protobuf.Timestamp grantDatetime = 4; + int32 descriptionGiftTextId = 5; + bytes equipmentData = 6; +} + +message GetGiftReceiveHistoryListResponse { + repeated ReceivedGift gift = 1; + map diffUserData = 99; +} + +message ReceivedGift { + GiftCommon giftCommon = 1; + google.protobuf.Timestamp receivedDatetime = 2; +} \ No newline at end of file diff --git a/server/proto/gimmick.proto b/server/proto/gimmick.proto new file mode 100644 index 0000000..c72c589 --- /dev/null +++ b/server/proto/gimmick.proto @@ -0,0 +1,64 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/data.proto"; + +package apb.api.gimmick; + +service GimmickService { + rpc UpdateSequence (UpdateSequenceRequest) returns (UpdateSequenceResponse); + rpc UpdateGimmickProgress (UpdateGimmickProgressRequest) returns (UpdateGimmickProgressResponse); + rpc InitSequenceSchedule (google.protobuf.Empty) returns (InitSequenceScheduleResponse); + rpc Unlock (UnlockRequest) returns (UnlockResponse); +} + +message UpdateSequenceRequest { + int32 gimmickSequenceScheduleId = 1; + int32 gimmickSequenceId = 2; +} + +message UpdateSequenceResponse { + map diffUserData = 99; +} + +message UpdateGimmickProgressRequest { + int32 gimmickSequenceScheduleId = 1; + int32 gimmickSequenceId = 2; + int32 gimmickId = 3; + int32 gimmickOrnamentIndex = 4; + int32 progressValueBit = 5; + int32 flowType = 6; +} + +message UpdateGimmickProgressResponse { + repeated GimmickReward gimmickOrnamentReward = 1; + bool isSequenceCleared = 2; + repeated GimmickReward gimmickSequenceClearReward = 3; + map diffUserData = 99; +} + +message GimmickReward { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; +} + +message InitSequenceScheduleResponse { + map diffUserData = 99; +} + +message UnlockRequest { + repeated GimmickKey gimmickKey = 1; +} + +message GimmickKey { + int32 gimmickSequenceScheduleId = 1; + int32 gimmickSequenceId = 2; + int32 gimmickId = 3; +} + +message UnlockResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/individualpop.proto b/server/proto/individualpop.proto new file mode 100644 index 0000000..8b5d8e6 --- /dev/null +++ b/server/proto/individualpop.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/data.proto"; + +package apb.api.individualpop; + +service IndividualpopService { + rpc GetUnreadPop (google.protobuf.Empty) returns (GetUnreadPopResponse); +} + +message GetUnreadPopResponse { + repeated string unreadPop = 1; + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/labyrinth.proto b/server/proto/labyrinth.proto new file mode 100644 index 0000000..065611f --- /dev/null +++ b/server/proto/labyrinth.proto @@ -0,0 +1,54 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.labyrinth; + +service LabyrinthService { + rpc UpdateSeasonData (UpdateSeasonDataRequest) returns (UpdateSeasonDataResponse); + rpc ReceiveStageClearReward (ReceiveStageClearRewardRequest) returns (ReceiveStageClearRewardResponse); + rpc ReceiveStageAccumulationReward (ReceiveStageAccumulationRewardRequest) returns (ReceiveStageAccumulationRewardResponse); +} + +message LabyrinthSeasonResult { + int32 eventQuestChapterId = 1; + int32 headQuestId = 2; + repeated LabyrinthReward seasonReward = 3; + int32 headStageOrder = 4; +} + +message LabyrinthReward { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; +} + +message UpdateSeasonDataRequest { + int32 eventQuestChapterId = 1; +} + +message UpdateSeasonDataResponse { + repeated LabyrinthSeasonResult seasonResult = 1; + map diffUserData = 99; +} + +message ReceiveStageClearRewardRequest { + int32 eventQuestChapterId = 1; + int32 stageOrder = 2; +} + +message ReceiveStageClearRewardResponse { + map diffUserData = 99; +} + +message ReceiveStageAccumulationRewardRequest { + int32 eventQuestChapterId = 1; + int32 stageOrder = 2; + int32 questMissionClearCount = 3; +} + +message ReceiveStageAccumulationRewardResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/loginbonus.proto b/server/proto/loginbonus.proto new file mode 100644 index 0000000..bca52e5 --- /dev/null +++ b/server/proto/loginbonus.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/data.proto"; + +package apb.api.loginBonus; + +service LoginBonusService { + rpc ReceiveStamp (google.protobuf.Empty) returns (ReceiveStampResponse); +} + +message ReceiveStampResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/material.proto b/server/proto/material.proto new file mode 100644 index 0000000..65047bd --- /dev/null +++ b/server/proto/material.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.material; + +service MaterialService { + rpc Sell (MaterialSellRequest) returns (MaterialSellResponse); +} + +message MaterialSellRequest { + repeated MaterialSellPossession materialPossession = 1; +} + +message MaterialSellPossession { + int32 materialId = 1; + int32 count = 2; +} + +message MaterialSellResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/mission.proto b/server/proto/mission.proto new file mode 100644 index 0000000..93e7500 --- /dev/null +++ b/server/proto/mission.proto @@ -0,0 +1,70 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.mission; + +service MissionService { + rpc ReceiveMissionRewardsById (ReceiveMissionRewardsByIdRequest) returns (ReceiveMissionRewardsResponse); + rpc UpdateMissionProgress (UpdateMissionProgressRequest) returns (UpdateMissionProgressResponse); + rpc ReceiveMissionPassRewards (ReceiveMissionPassRewardsRequest) returns (ReceiveMissionPassRewardsResponse); +} + +message CageMeasurableValues { + int32 runningDistanceMeters = 1; + int32 mamaTappedCount = 2; +} + +message ReceiveMissionRewardsByIdRequest { + repeated int32 missionId = 1; +} + +message ReceiveMissionRewardsResponse { + repeated MissionReward receivedPossession = 1; + repeated MissionReward expiredPossession = 2; + repeated MissionReward overflowPossession = 3; + map diffUserData = 99; +} + +message MissionReward { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; +} + +message UpdateMissionProgressRequest { + CageMeasurableValues cageMeasurableValues = 50; + PictureBookMeasurableValues pictureBookMeasurableValues = 51; +} + +message PictureBookMeasurableValues { + int32 defeatWizardCount = 1; + RhythmInteractionMeasurableValues rhythmInteractionMeasurableValues = 2; +} + +message RhythmInteractionMeasurableValues { + int32 liveTypeId = 1; + int32 tapCount = 2; +} + +message UpdateMissionProgressResponse { + map diffUserData = 99; +} + +message ReceiveMissionPassRewardsRequest { + int32 missionPassId = 1; +} + +message ReceiveMissionPassRewardsResponse { + repeated MissionPassReward receivedPossession = 1; + repeated MissionPassReward overflowPossession = 2; + map diffUserData = 99; +} + +message MissionPassReward { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; +} \ No newline at end of file diff --git a/server/proto/movie.proto b/server/proto/movie.proto new file mode 100644 index 0000000..8a8a64e --- /dev/null +++ b/server/proto/movie.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.movie; + +service MovieService { + rpc SaveViewedMovie (SaveViewedMovieRequest) returns (SaveViewedMovieResponse); +} + +message SaveViewedMovieRequest { + repeated int32 movieId = 1; +} + +message SaveViewedMovieResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/navicutin.proto b/server/proto/navicutin.proto new file mode 100644 index 0000000..5160d79 --- /dev/null +++ b/server/proto/navicutin.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.navicutin; + +service NaviCutInService { + rpc RegisterPlayed (RegisterPlayedRequest) returns (RegisterPlayedResponse); +} + +message RegisterPlayedRequest { + int32 naviCutId = 1; +} + +message RegisterPlayedResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/notification.proto b/server/proto/notification.proto new file mode 100644 index 0000000..16fbdab --- /dev/null +++ b/server/proto/notification.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/data.proto"; + +package apb.api.notification; + +service NotificationService { + rpc GetHeaderNotification (google.protobuf.Empty) returns (GetHeaderNotificationResponse); +} + +message GetHeaderNotificationResponse { + int32 giftNotReceiveCount = 1; + int32 friendRequestReceiveCount = 2; + bool isExistUnreadInformation = 3; + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/omikuji.proto b/server/proto/omikuji.proto new file mode 100644 index 0000000..4a023cd --- /dev/null +++ b/server/proto/omikuji.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.omikuji; + +service OmikujiService { + rpc OmikujiDraw (OmikujiDrawRequest) returns (OmikujiDrawResponse); +} + +message OmikujiDrawRequest { + int32 omikujiId = 1; +} + +message OmikujiDrawResponse { + int32 omikujiResultAssetId = 1; + repeated OmikujiItem omikujiItem = 2; + map diffUserData = 99; +} + +message OmikujiItem { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; +} \ No newline at end of file diff --git a/server/proto/parts.proto b/server/proto/parts.proto new file mode 100644 index 0000000..7869513 --- /dev/null +++ b/server/proto/parts.proto @@ -0,0 +1,108 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.parts; + +service PartsService { + rpc Sell (PartsSellRequest) returns (PartsSellResponse); + rpc Protect (PartsProtectRequest) returns (PartsProtectResponse); + rpc Unprotect (PartsUnprotectRequest) returns (PartsUnprotectResponse); + rpc Enhance (PartsEnhanceRequest) returns (PartsEnhanceResponse); + rpc UpdatePresetName (PartsUpdatePresetNameRequest) returns (PartsUpdatePresetNameResponse); + rpc UpdatePresetTagNumber (PartsUpdatePresetTagNumberRequest) returns (PartsUpdatePresetTagNumberResponse); + rpc UpdatePresetTagName (PartsUpdatePresetTagNameRequest) returns (PartsUpdatePresetTagNameResponse); + rpc ReplacePreset (PartsReplacePresetRequest) returns (PartsReplacePresetResponse); + rpc CopyPreset (PartsCopyPresetRequest) returns (PartsCopyPresetResponse); + rpc RemovePreset (PartsRemovePresetRequest) returns (PartsRemovePresetResponse); +} + +message PartsSellRequest { + repeated string userPartsUuid = 1; +} + +message PartsSellResponse { + map diffUserData = 99; +} + +message PartsProtectRequest { + repeated string userPartsUuid = 1; +} + +message PartsProtectResponse { + map diffUserData = 99; +} + +message PartsUnprotectRequest { + repeated string userPartsUuid = 1; +} + +message PartsUnprotectResponse { + map diffUserData = 99; +} + +message PartsEnhanceRequest { + string userPartsUuid = 1; +} + +message PartsEnhanceResponse { + bool isSuccess = 1; + map diffUserData = 99; +} + +message PartsUpdatePresetNameRequest { + int32 userPartsPresetNumber = 1; + string name = 2; +} + +message PartsUpdatePresetNameResponse { + map diffUserData = 99; +} + +message PartsUpdatePresetTagNumberRequest { + int32 userPartsPresetNumber = 1; + int32 userPartsPresetTagNumber = 2; +} + +message PartsUpdatePresetTagNumberResponse { + map diffUserData = 99; +} + +message PartsUpdatePresetTagNameRequest { + int32 userPartsPresetTagNumber = 1; + string name = 2; +} + +message PartsUpdatePresetTagNameResponse { + map diffUserData = 99; +} + +message PartsReplacePresetRequest { + int32 userPartsPresetNumber = 1; + string userPartsUuid01 = 2; + string userPartsUuid02 = 3; + string userPartsUuid03 = 4; +} + +message PartsReplacePresetResponse { + map diffUserData = 99; +} + +message PartsCopyPresetRequest { + int32 fromUserPartsPresetNumber = 1; + int32 toUserPartsPresetNumber = 2; +} + +message PartsCopyPresetResponse { + map diffUserData = 99; +} + +message PartsRemovePresetRequest { + int32 userPartsPresetNumber = 1; +} + +message PartsRemovePresetResponse { + map diffUserData = 99; +} diff --git a/server/proto/portalcage.proto b/server/proto/portalcage.proto new file mode 100644 index 0000000..3ea4ddb --- /dev/null +++ b/server/proto/portalcage.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/data.proto"; + +package apb.api.portalcage; + +service PortalCageService { + rpc UpdatePortalCageSceneProgress (UpdatePortalCageSceneProgressRequest) returns (UpdatePortalCageSceneProgressResponse); + rpc GetDropItem (google.protobuf.Empty) returns (GetDropItemResponse); +} + +message UpdatePortalCageSceneProgressRequest { + int32 portalCageSceneId = 1; +} + +message UpdatePortalCageSceneProgressResponse { + map diffUserData = 99; +} + +message GetDropItemResponse { + repeated PortalCageDropItem portalCageDropItem = 1; + map diffUserData = 99; +} + +message PortalCageDropItem { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; +} \ No newline at end of file diff --git a/server/proto/pvp.proto b/server/proto/pvp.proto new file mode 100644 index 0000000..3bedc7e --- /dev/null +++ b/server/proto/pvp.proto @@ -0,0 +1,279 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/data.proto"; +import "google/protobuf/timestamp.proto"; + +package apb.api.pvp; + +service PvpService { + rpc GetTopData (google.protobuf.Empty) returns (GetTopDataResponse); + rpc GetMatchingList (google.protobuf.Empty) returns (GetMatchingListResponse); + rpc UpdateMatchingList (google.protobuf.Empty) returns (UpdateMatchingListResponse); + rpc StartBattle (StartBattleRequest) returns (StartBattleResponse); + rpc FinishBattle (FinishBattleRequest) returns (FinishBattleResponse); + rpc GetRanking (GetRankingRequest) returns (GetRankingResponse); + rpc GetSeasonResult (google.protobuf.Empty) returns (GetSeasonResultResponse); + rpc GetAttackLogList (google.protobuf.Empty) returns (GetAttackLogListResponse); + rpc GetDefenseLogList (google.protobuf.Empty) returns (GetDefenseLogListResponse); +} + +message WeeklyGradeResult { + int32 targetSeasonId = 1; + int32 pvpPoint = 2; + int32 pvpGradeWeeklyRewardGroupId = 3; +} + +message SeasonRankResult { + int32 targetSeasonId = 1; + int32 rank = 2; + int32 pvpSeasonRankRewardGroupId = 3; +} + +message WeeklyRankResult { + int32 targetSeasonId = 1; + int32 rank = 2; + int32 pvpWeeklyRankRewardGroupId = 3; +} + +message GetTopDataResponse { + int32 currentSeasonId = 1; + int32 pvpPoint = 2; + int32 rank = 3; + WeeklyGradeResult weeklyGradeResult = 4; + SeasonRankResult seasonRankResult = 5; + WeeklyRankResult weeklyRankResult = 6; + map diffUserData = 99; +} + +message GetMatchingListResponse { + repeated MatchingOpponent matching = 1; + map diffUserData = 99; +} + +message MatchingOpponent { + int64 playerId = 1; + string name = 2; + int32 pvpPoint = 3; + int32 rank = 4; + int32 deckPower = 5; + repeated int32 deckMainWeaponAttributeType = 6; + int32 mostPowerfulCostumeId = 7; +} + +message UpdateMatchingListResponse { + repeated MatchingOpponent matching = 1; + map diffUserData = 99; +} + +message StartBattleRequest { + int64 opponentPlayerId = 1; + int32 useDeckNumber = 2; +} + +message StartBattleResponse { + repeated PvpDeckCharacter opponentDeckCharacter = 1; + map diffUserData = 99; +} + +message PvpDeckCharacter { + CostumeInfo costume = 1; + CompanionInfo companion = 2; + WeaponInfo mainWeapon = 3; + repeated WeaponInfo subWeapon = 4; + repeated PartsInfo parts = 5; + repeated CharacterBoardAbilityInfo characterBoardAbilities = 6; + repeated CharacterBoardStatusUpInfo characterBoardStatusUps = 7; + repeated CostumeLevelBonusStatusUpInfo costumeLevelBonusStatusUps = 8; + repeated AwakenAbilityInfo awakenAbilities = 9; + repeated AwakenStatusUpInfo awakenStatusUps = 10; + ThoughtInfo thought = 11; + repeated StainedGlassStatusUpInfo stainedGlassStatusUps = 12; + repeated CostumeLotteryEffectAbilityInfo costumeLotteryEffectAbilities = 13; + repeated CostumeLotteryEffectStatusUpInfo costumeLotteryEffectStatusUps = 14; +} + +message CostumeInfo { + int32 costumeId = 1; + int32 limitBreakCount = 2; + int32 level = 3; + int32 activeSkillLevel = 4; + int32 characterLevel = 5; + int32 costumeLotteryEffectUnlockedSlotCount = 6; +} + +message CompanionInfo { + int32 companionId = 1; + int32 level = 2; +} + +message WeaponInfo { + int32 weaponId = 1; + int32 limitBreakCount = 2; + int32 level = 3; + repeated WeaponAbilityInfo weaponAbility = 4; + repeated WeaponSkillInfo weaponSkill = 5; + AwakenAbilityInfo weaponAwakenAbility = 6; + repeated AwakenStatusUpInfo weaponAwakenStatusUps = 7; +} + +message WeaponAbilityInfo { + int32 abilityId = 1; + int32 level = 2; +} + +message WeaponSkillInfo { + int32 skillId = 1; + int32 level = 2; +} + +message AwakenAbilityInfo { + int32 abilityId = 1; + int32 level = 2; +} + +message AwakenStatusUpInfo { + int32 statusCalculationType = 1; + int32 hp = 2; + int32 attack = 3; + int32 vitality = 4; + int32 agility = 5; + int32 criticalRatio = 6; + int32 criticalAttack = 7; +} + +message PartsInfo { + int32 partsId = 1; + int32 level = 2; + int32 partsMainStatusId = 3; + repeated PartsSubStatusInfo subPartsStatus = 4; +} + +message PartsSubStatusInfo { + int32 level = 1; + int32 statusKindType = 2; + int32 statusCalculationType = 3; + int32 statusChangeValue = 4; +} + +message CharacterBoardAbilityInfo { + int32 abilityId = 1; + int32 level = 2; +} + +message CharacterBoardStatusUpInfo { + int32 statusCalculationType = 1; + int32 hp = 2; + int32 attack = 3; + int32 vitality = 4; + int32 agility = 5; + int32 criticalRatio = 6; + int32 criticalAttack = 7; +} + +message CostumeLevelBonusStatusUpInfo { + int32 statusCalculationType = 1; + int32 hp = 2; + int32 attack = 3; + int32 vitality = 4; + int32 agility = 5; + int32 criticalRatio = 6; + int32 criticalAttack = 7; +} + +message ThoughtInfo { + int32 thoughtId = 1; +} + +message StainedGlassStatusUpInfo { + int32 statusCalculationType = 1; + int32 hp = 2; + int32 attack = 3; + int32 vitality = 4; + int32 agility = 5; + int32 criticalRatio = 6; + int32 criticalAttack = 7; +} + +message CostumeLotteryEffectAbilityInfo { + int32 abilityId = 1; + int32 level = 2; +} + +message CostumeLotteryEffectStatusUpInfo { + int32 statusCalculationType = 1; + int32 hp = 2; + int32 attack = 3; + int32 vitality = 4; + int32 agility = 5; + int32 criticalRatio = 6; + int32 criticalAttack = 7; +} + +message FinishBattleRequest { + int64 opponentPlayerId = 1; + bool isVictory = 2; +} + +message FinishBattleResponse { + int32 beforePvpPoint = 1; + int32 beforeRank = 2; + int32 afterPvpPoint = 3; + int32 afterRank = 4; + int32 pvpGradeOneMatchRewardId = 5; + int32 pvpGradeGroupId = 6; + map diffUserData = 99; +} + +message GetRankingRequest { + int32 rankFrom = 1; +} + +message GetRankingResponse { + repeated RankingUser rankingUser = 1; + int32 userCount = 2; + int32 rankingPosition = 3; + map diffUserData = 99; +} + +message RankingUser { + int32 rank = 1; + int64 playerId = 2; + string name = 3; + int32 pvpPoint = 4; + int32 deckPower = 5; + int32 favoriteCostumeId = 6; +} + +message GetSeasonResultResponse { + int32 attackWinCount = 1; + int32 attackLoseCount = 2; + int32 attackPvpPoint = 3; + int32 defenseWinRatePermil = 4; + int32 defensePvpPoint = 5; + map diffUserData = 99; +} + +message GetAttackLogListResponse { + repeated BattleLog attackLog = 1; + map diffUserData = 99; +} + +message BattleLog { + int64 playerId = 1; + string name = 2; + int32 pvpPoint = 3; + int32 deckPower = 4; + repeated int32 deckCostumeId = 5; + bool isVictory = 6; + google.protobuf.Timestamp battleDatetime = 7; + int32 fluctuatedPvpPoint = 8; + int32 rank = 9; +} + +message GetDefenseLogListResponse { + repeated BattleLog defenseLog = 1; + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/quest.proto b/server/proto/quest.proto new file mode 100644 index 0000000..9fec90a --- /dev/null +++ b/server/proto/quest.proto @@ -0,0 +1,333 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/data.proto"; +import "proto/mission.proto"; + +package apb.api.quest; + +service QuestService { + rpc UpdateMainFlowSceneProgress (UpdateMainFlowSceneProgressRequest) returns (UpdateMainFlowSceneProgressResponse); + rpc UpdateReplayFlowSceneProgress (UpdateReplayFlowSceneProgressRequest) returns (UpdateReplayFlowSceneProgressResponse); + rpc UpdateMainQuestSceneProgress (UpdateMainQuestSceneProgressRequest) returns (UpdateMainQuestSceneProgressResponse); + rpc UpdateExtraQuestSceneProgress (UpdateExtraQuestSceneProgressRequest) returns (UpdateExtraQuestSceneProgressResponse); + rpc UpdateEventQuestSceneProgress (UpdateEventQuestSceneProgressRequest) returns (UpdateEventQuestSceneProgressResponse); + rpc StartMainQuest (StartMainQuestRequest) returns (StartMainQuestResponse); + rpc RestartMainQuest (RestartMainQuestRequest) returns (RestartMainQuestResponse); + rpc FinishMainQuest (FinishMainQuestRequest) returns (FinishMainQuestResponse); + rpc StartExtraQuest (StartExtraQuestRequest) returns (StartExtraQuestResponse); + rpc RestartExtraQuest (RestartExtraQuestRequest) returns (RestartExtraQuestResponse); + rpc FinishExtraQuest (FinishExtraQuestRequest) returns (FinishExtraQuestResponse); + rpc StartEventQuest (StartEventQuestRequest) returns (StartEventQuestResponse); + rpc RestartEventQuest (RestartEventQuestRequest) returns (RestartEventQuestResponse); + rpc FinishEventQuest (FinishEventQuestRequest) returns (FinishEventQuestResponse); + rpc FinishAutoOrbit (google.protobuf.Empty) returns (FinishAutoOrbitResponse); + rpc SetRoute (SetRouteRequest) returns (SetRouteResponse); + rpc SetQuestSceneChoice (SetQuestSceneChoiceRequest) returns (SetQuestSceneChoiceResponse); + rpc ReceiveTowerAccumulationReward (ReceiveTowerAccumulationRewardRequest) returns (ReceiveTowerAccumulationRewardResponse); + rpc SkipQuest (SkipQuestRequest) returns (SkipQuestResponse); + rpc SkipQuestBulk (SkipQuestBulkRequest) returns (SkipQuestBulkResponse); + rpc SetAutoSaleSetting (SetAutoSaleSettingRequest) returns (SetAutoSaleSettingResponse); + rpc StartGuerrillaFreeOpen (google.protobuf.Empty) returns (StartGuerrillaFreeOpenResponse); + rpc ResetLimitContentQuestProgress (ResetLimitContentQuestProgressRequest) returns (ResetLimitContentQuestProgressResponse); + rpc ReceiveDailyQuestGroupCompleteReward (google.protobuf.Empty) returns (ReceiveDailyQuestGroupCompleteRewardResponse); +} + +message UpdateMainFlowSceneProgressRequest { + int32 questSceneId = 1; +} + +message UpdateMainFlowSceneProgressResponse { + map diffUserData = 99; +} + +message UpdateReplayFlowSceneProgressRequest { + int32 questSceneId = 1; +} + +message UpdateReplayFlowSceneProgressResponse { + map diffUserData = 99; +} + +message UpdateMainQuestSceneProgressRequest { + int32 questSceneId = 1; +} + +message UpdateMainQuestSceneProgressResponse { + map diffUserData = 99; +} + +message UpdateExtraQuestSceneProgressRequest { + int32 questSceneId = 1; +} + +message UpdateExtraQuestSceneProgressResponse { + map diffUserData = 99; +} + +message UpdateEventQuestSceneProgressRequest { + int32 questSceneId = 1; +} + +message UpdateEventQuestSceneProgressResponse { + map diffUserData = 99; +} + +message StartMainQuestRequest { + int32 questId = 1; + bool isMainFlow = 2; + int32 userDeckNumber = 3; + bool isBattleOnly = 4; + int32 maxAutoOrbitCount = 5; + bool isReplayFlow = 6; + apb.api.mission.CageMeasurableValues cageMeasurableValues = 50; +} + +message StartMainQuestResponse { + repeated BattleDropReward battleDropReward = 1; + map diffUserData = 99; +} + +message BattleDropReward { + int32 questSceneId = 1; + int32 battleDropCategoryId = 2; + int32 battleDropEffectId = 3; +} + +message RestartMainQuestRequest { + int32 questId = 1; + bool isMainFlow = 2; +} + +message RestartMainQuestResponse { + repeated BattleDropReward battleDropReward = 1; + bytes battleBinary = 2; + int32 deckNumber = 3; + map diffUserData = 99; +} + +message FinishMainQuestRequest { + int32 questId = 1; + bool isRetired = 2; + bool isMainFlow = 3; + bool isAnnihilated = 4; + bool isAutoOrbit = 5; + int32 storySkipType = 6; + bool isReplayFlow = 7; + string vt = 200; +} + +message FinishMainQuestResponse { + repeated QuestReward dropReward = 1; + repeated QuestReward firstClearReward = 2; + repeated QuestReward missionClearReward = 3; + repeated QuestReward missionClearCompleteReward = 4; + repeated QuestReward autoOrbitResult = 5; + bool isBigWin = 6; + repeated int32 bigWinClearedQuestMissionIdList = 7; + repeated QuestReward replayFlowFirstClearReward = 8; + repeated QuestReward userStatusCampaignReward = 9; + QuestAutoOrbitResult autoOrbitReward = 10; + map diffUserData = 99; +} + +message QuestReward { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; + int32 rewardEffectId = 4; + bool isAutoSale = 5; + bytes equipmentData = 6; +} + +message QuestAutoOrbitResult { + repeated QuestReward dropReward = 1; + repeated QuestReward userStatusCampaignReward = 2; +} + +message StartExtraQuestRequest { + int32 questId = 1; + int32 userDeckNumber = 2; +} + +message StartExtraQuestResponse { + repeated BattleDropReward battleDropReward = 1; + map diffUserData = 99; +} + +message RestartExtraQuestRequest { + int32 questId = 1; +} + +message RestartExtraQuestResponse { + repeated BattleDropReward battleDropReward = 1; + bytes battleBinary = 2; + int32 deckNumber = 3; + map diffUserData = 99; +} + +message FinishExtraQuestRequest { + int32 questId = 1; + bool isRetired = 2; + bool isAnnihilated = 3; + int32 storySkipType = 4; + string vt = 200; +} + +message FinishExtraQuestResponse { + repeated QuestReward dropReward = 1; + repeated QuestReward firstClearReward = 2; + repeated QuestReward missionClearReward = 3; + repeated QuestReward missionClearCompleteReward = 4; + bool isBigWin = 5; + repeated int32 bigWinClearedQuestMissionIdList = 6; + repeated QuestReward userStatusCampaignReward = 7; + map diffUserData = 99; +} + +message StartEventQuestRequest { + int32 eventQuestChapterId = 1; + int32 questId = 2; + int32 userDeckNumber = 3; + bool isBattleOnly = 4; + int32 maxAutoOrbitCount = 5; +} + +message StartEventQuestResponse { + repeated BattleDropReward battleDropReward = 1; + map diffUserData = 99; +} + +message RestartEventQuestRequest { + int32 eventQuestChapterId = 1; + int32 questId = 2; +} + +message RestartEventQuestResponse { + repeated BattleDropReward battleDropReward = 1; + bytes battleBinary = 2; + int32 deckNumber = 3; + map diffUserData = 99; +} + +message FinishEventQuestRequest { + int32 eventQuestChapterId = 1; + int32 questId = 2; + bool isRetired = 3; + bool isAnnihilated = 4; + bool isAutoOrbit = 5; + int32 storySkipType = 6; + string vt = 200; +} + +message FinishEventQuestResponse { + repeated QuestReward dropReward = 1; + repeated QuestReward firstClearReward = 2; + repeated QuestReward missionClearReward = 3; + repeated QuestReward missionClearCompleteReward = 4; + repeated QuestReward autoOrbitResult = 5; + bool isBigWin = 6; + repeated int32 bigWinClearedQuestMissionIdList = 7; + repeated QuestReward userStatusCampaignReward = 8; + QuestAutoOrbitResult autoOrbitReward = 9; + map diffUserData = 99; +} + +message FinishAutoOrbitResponse { + repeated QuestReward autoOrbitResult = 1; + QuestAutoOrbitResult autoOrbitReward = 2; + map diffUserData = 99; +} + +message SetRouteRequest { + int32 mainQuestRouteId = 1; +} + +message SetRouteResponse { + map diffUserData = 99; +} + +message SetQuestSceneChoiceRequest { + int32 questSceneId = 1; + int32 choiceNumber = 2; + int32 questFlowType = 3; +} + +message SetQuestSceneChoiceResponse { + map diffUserData = 99; +} + +message ReceiveTowerAccumulationRewardRequest { + int32 eventQuestChapterId = 1; + int32 targetMissionClearCount = 2; +} + +message ReceiveTowerAccumulationRewardResponse { + map diffUserData = 99; +} + +message SkipQuestRequest { + int32 questId = 1; + int32 questType = 2; + int32 userDeckNumber = 3; + int32 skipCount = 4; + repeated UseEffectItem useEffectItem = 5; + int32 questChapterId = 6; +} + +message UseEffectItem { + int32 consumableItemId = 1; + int32 count = 2; +} + +message SkipQuestResponse { + repeated QuestReward dropReward = 1; + repeated QuestReward userStatusCampaignReward = 2; + map diffUserData = 99; +} + +message SkipQuestBulkRequest { + repeated SkipQuestInfo skipQuestInfo = 1; + int32 userDeckNumber = 2; + repeated UseEffectItem useEffectItem = 3; +} + +message SkipQuestInfo { + int32 questId = 1; + int32 questType = 2; + int32 questChapterId = 3; + int32 skipCount = 4; +} + +message SkipQuestBulkResponse { + repeated QuestReward dropReward = 1; + repeated QuestReward userStatusCampaignReward = 2; + map diffUserData = 99; +} + +message SetAutoSaleSettingRequest { + map autoSaleSettingItem = 1; +} + +message SetAutoSaleSettingResponse { + map diffUserData = 99; +} + +message StartGuerrillaFreeOpenResponse { + map diffUserData = 99; +} + +message ResetLimitContentQuestProgressRequest { + int32 eventQuestChapterId = 1; + int32 questId = 2; +} + +message ResetLimitContentQuestProgressResponse { + map diffUserData = 99; +} + +message ReceiveDailyQuestGroupCompleteRewardResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/reward.proto b/server/proto/reward.proto new file mode 100644 index 0000000..1c4835e --- /dev/null +++ b/server/proto/reward.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/pvp.proto"; +import "proto/data.proto"; +import "proto/bighunt.proto"; +import "proto/labyrinth.proto"; + +package apb.api.reward; + +service RewardService { + rpc ReceivePvpReward (google.protobuf.Empty) returns (ReceivePvpRewardResponse); + rpc ReceiveBigHuntReward (google.protobuf.Empty) returns (ReceiveBigHuntRewardResponse); + rpc ReceiveLabyrinthSeasonReward (google.protobuf.Empty) returns (ReceiveLabyrinthSeasonRewardResponse); + rpc ReceiveMissionPassRemainingReward (google.protobuf.Empty) returns (ReceiveMissionPassRemainingRewardResponse); +} + +message ReceivePvpRewardResponse { + apb.api.pvp.WeeklyGradeResult weeklyGradeResult = 1; + apb.api.pvp.SeasonRankResult seasonRankResult = 2; + apb.api.pvp.WeeklyRankResult weeklyRankResult = 3; + map diffUserData = 99; +} + +message ReceiveBigHuntRewardResponse { + repeated apb.api.bighunt.WeeklyScoreResult weeklyScoreResult = 1; + repeated apb.api.bighunt.BigHuntReward weeklyScoreReward = 2; + bool isReceivedWeeklyScoreReward = 3; + repeated apb.api.bighunt.BigHuntReward lastWeekWeeklyScoreReward = 4; + map diffUserData = 99; +} + +message ReceiveLabyrinthSeasonRewardResponse { + repeated apb.api.labyrinth.LabyrinthSeasonResult seasonResult = 1; + map diffUserData = 99; +} + +message ReceiveMissionPassRemainingRewardResponse { + int32 rewardReceivedMissionPassId = 1; + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/shop.proto b/server/proto/shop.proto new file mode 100644 index 0000000..70674ad --- /dev/null +++ b/server/proto/shop.proto @@ -0,0 +1,73 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/data.proto"; + +package apb.api.shop; + +service ShopService { + rpc Buy (BuyRequest) returns (BuyResponse); + rpc RefreshUserData (RefreshRequest) returns (RefreshResponse); + rpc GetCesaLimit (google.protobuf.Empty) returns (GetCesaLimitResponse); + rpc CreatePurchaseTransaction (CreatePurchaseTransactionRequest) returns (CreatePurchaseTransactionResponse); + rpc PurchaseGooglePlayStoreProduct (PurchaseGooglePlayStoreProductRequest) returns (PurchaseGooglePlayStoreProductResponse); +} + +message BuyRequest { + int32 shopId = 1; + map shopItems = 2; +} + +message BuyResponse { + repeated Possession overflowPossession = 1; + map diffUserData = 99; +} + +message Possession { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; +} + +message RefreshRequest { + bool isGemUsed = 1; +} + +message RefreshResponse { + map diffUserData = 99; +} + +message GetCesaLimitResponse { + repeated CesaLimit cesaLimit = 1; + map diffUserData = 99; +} + +message CesaLimit { + int32 age = 1; + int32 limit = 2; +} + +message CreatePurchaseTransactionRequest { + int32 shopId = 1; + int32 shopItemId = 2; + string productId = 3; + string amazonUserId = 4; +} + +message CreatePurchaseTransactionResponse { + string purchaseTransactionId = 1; + map diffUserData = 99; +} + +message PurchaseGooglePlayStoreProductRequest { + string purchaseTransactionId = 1; + string purchaseData = 2; + string dataSignature = 3; +} + +message PurchaseGooglePlayStoreProductResponse { + repeated Possession overflowPossession = 1; + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/sidestoryquest.proto b/server/proto/sidestoryquest.proto new file mode 100644 index 0000000..ec29878 --- /dev/null +++ b/server/proto/sidestoryquest.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.sidestoryquest; + +service SideStoryQuestService { + rpc MoveSideStoryQuestProgress (MoveSideStoryQuestRequest) returns (MoveSideStoryQuestResponse); + rpc UpdateSideStoryQuestSceneProgress (UpdateSideStoryQuestSceneProgressRequest) returns (UpdateSideStoryQuestSceneProgressResponse); +} + +message MoveSideStoryQuestRequest { + int32 sideStoryQuestId = 1; +} + +message MoveSideStoryQuestResponse { + map diffUserData = 99; +} + +message UpdateSideStoryQuestSceneProgressRequest { + int32 sideStoryQuestId = 1; + int32 sideStoryQuestSceneId = 2; +} + +message UpdateSideStoryQuestSceneProgressResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/tutorial.proto b/server/proto/tutorial.proto new file mode 100644 index 0000000..3a72861 --- /dev/null +++ b/server/proto/tutorial.proto @@ -0,0 +1,42 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; +import "proto/deck.proto"; + +package apb.api.tutorial; + +service TutorialService { + rpc SetTutorialProgress (SetTutorialProgressRequest) returns (SetTutorialProgressResponse); + rpc SetTutorialProgressAndReplaceDeck (SetTutorialProgressAndReplaceDeckRequest) returns (SetTutorialProgressAndReplaceDeckResponse); +} + +message SetTutorialProgressRequest { + int32 tutorialType = 1; + int32 progressPhase = 2; + int32 choiceId = 3; +} + +message SetTutorialProgressResponse { + repeated TutorialChoiceReward tutorialChoiceReward = 1; + map diffUserData = 99; +} + +message TutorialChoiceReward { + int32 possessionType = 1; + int32 possessionId = 2; + int32 count = 3; +} + +message SetTutorialProgressAndReplaceDeckRequest { + int32 tutorialType = 1; + int32 progressPhase = 2; + int32 deckType = 3; + int32 userDeckNumber = 4; + apb.api.deck.Deck deck = 5; +} + +message SetTutorialProgressAndReplaceDeckResponse { + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/user.proto b/server/proto/user.proto new file mode 100644 index 0000000..748d9ab --- /dev/null +++ b/server/proto/user.proto @@ -0,0 +1,262 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "google/protobuf/empty.proto"; +import "proto/data.proto"; +import "google/protobuf/timestamp.proto"; + +package apb.api.user; + +service UserService { + rpc RegisterUser (RegisterUserRequest) returns (RegisterUserResponse); + rpc TransferUser (TransferUserRequest) returns (TransferUserResponse); + rpc Auth (AuthUserRequest) returns (AuthUserResponse); + rpc GameStart (google.protobuf.Empty) returns (GameStartResponse); + rpc SetUserName (SetUserNameRequest) returns (SetUserNameResponse); + rpc SetUserMessage (SetUserMessageRequest) returns (SetUserMessageResponse); + rpc SetUserFavoriteCostumeId (SetUserFavoriteCostumeIdRequest) returns (SetUserFavoriteCostumeIdResponse); + rpc GetUserProfile (GetUserProfileRequest) returns (GetUserProfileResponse); + rpc SetBirthYearMonth (SetBirthYearMonthRequest) returns (SetBirthYearMonthResponse); + rpc GetBirthYearMonth (google.protobuf.Empty) returns (GetBirthYearMonthResponse); + rpc GetChargeMoney (google.protobuf.Empty) returns (GetChargeMoneyResponse); + rpc SetUserSetting (SetUserSettingRequest) returns (SetUserSettingResponse); + rpc GetAndroidArgs (GetAndroidArgsRequest) returns (GetAndroidArgsResponse); + rpc GetBackupToken (GetBackupTokenRequest) returns (GetBackupTokenResponse); + rpc CheckTransferSetting (google.protobuf.Empty) returns (CheckTransferSettingResponse); + rpc SetFacebookAccount (SetFacebookAccountRequest) returns (SetFacebookAccountResponse); + rpc UnsetFacebookAccount (google.protobuf.Empty) returns (UnsetFacebookAccountResponse); + rpc SetAppleAccount (SetAppleAccountRequest) returns (SetAppleAccountResponse); + rpc TransferUserByFacebook (TransferUserByFacebookRequest) returns (TransferUserByFacebookResponse); + rpc TransferUserByApple (TransferUserByAppleRequest) returns (TransferUserByAppleResponse); + rpc GetUserGamePlayNote (GetUserGamePlayNoteRequest) returns (GetUserGamePlayNoteResponse); +} + +message RegisterUserRequest { + string uuid = 1; + string terminalId = 2; + string registerSignature = 3; +} + +message RegisterUserResponse { + int64 userId = 1; + string signature = 2; + map diffUserData = 99; +} + +message TransferUserRequest { + string uuid = 1; +} + +message TransferUserResponse { + int64 userId = 1; + string signature = 2; + map diffUserData = 99; +} + +message AuthUserRequest { + string uuid = 1; + string signature = 2; + string advertisingId = 3; + bool isTrackingEnabled = 4; + UserDeviceInherent deviceInherent = 5; + string tr = 6; +} + +message UserDeviceInherent { + string identifierForVendor = 1; + string deviceToken = 2; + string macAddress = 3; + string registrationId = 4; +} + +message AuthUserResponse { + string sessionKey = 1; + google.protobuf.Timestamp expireDatetime = 2; + string signature = 3; + int64 userId = 4; + map diffUserData = 99; +} + +message GameStartResponse { + map diffUserData = 99; +} + +message SetUserNameRequest { + string name = 1; +} + +message SetUserNameResponse { + map diffUserData = 99; +} + +message SetUserMessageRequest { + string message = 1; +} + +message SetUserMessageResponse { + map diffUserData = 99; +} + +message SetUserFavoriteCostumeIdRequest { + int32 favoriteCostumeId = 1; +} + +message SetUserFavoriteCostumeIdResponse { + map diffUserData = 99; +} + +message GetUserProfileRequest { + int64 playerId = 1; +} + +message GetUserProfileResponse { + int32 level = 1; + string name = 2; + int32 favoriteCostumeId = 3; + string message = 4; + bool isFriend = 5; + ProfileDeck latestUsedDeck = 6; + ProfilePvpInfo pvpInfo = 7; + GamePlayHistory gamePlayHistory = 8; + map diffUserData = 99; +} + +message ProfileDeck { + int32 power = 1; + repeated ProfileDeckCharacter deckCharacter = 2; +} + +message ProfileDeckCharacter { + int32 costumeId = 1; + int32 mainWeaponId = 2; + int32 mainWeaponLevel = 3; +} + +message ProfilePvpInfo { + int32 currentRank = 1; + int32 currentGradeId = 2; + int32 maxSeasonRank = 3; +} + +message GamePlayHistory { + repeated PlayHistoryItem historyItem = 1; + repeated PlayHistoryCategoryGraphItem historyCategoryGraphItem = 2; +} + +message PlayHistoryItem { + int32 historyItemId = 1; + int64 count = 2; +} + +message PlayHistoryCategoryGraphItem { + int32 categoryTypeId = 1; + int32 progressPermil = 2; +} + +message SetBirthYearMonthRequest { + int32 birthYear = 1; + int32 birthMonth = 2; +} + +message SetBirthYearMonthResponse { + map diffUserData = 99; +} + +message GetBirthYearMonthResponse { + int32 birthYear = 1; + int32 birthMonth = 2; + map diffUserData = 99; +} + +message GetChargeMoneyResponse { + int64 chargeMoneyThisMonth = 1; + map diffUserData = 99; +} + +message SetUserSettingRequest { + bool isNotifyPurchaseAlert = 1; +} + +message SetUserSettingResponse { + map diffUserData = 99; +} + +message GetAndroidArgsRequest { + string uuid = 1; + string signature = 2; + UserDeviceInherent deviceInherent = 3; + string packageName = 4; +} + +message GetAndroidArgsResponse { + string nonce = 1; + string apiKey = 2; + map diffUserData = 99; +} + +message GetBackupTokenRequest { + string uuid = 1; +} + +message GetBackupTokenResponse { + string backupToken = 1; + map diffUserData = 99; +} + +message CheckTransferSettingResponse { + map diffUserData = 99; +} + +message SetFacebookAccountRequest { + string token = 1; +} + +message SetFacebookAccountResponse { + map diffUserData = 99; +} + +message UnsetFacebookAccountResponse { + map diffUserData = 99; +} + +message SetAppleAccountRequest { + string token = 1; +} + +message SetAppleAccountResponse { + map diffUserData = 99; +} + +message TransferUserByFacebookRequest { + string token = 1; + string uuid = 2; + string terminalId = 3; +} + +message TransferUserByFacebookResponse { + int64 userId = 1; + string signature = 2; + map diffUserData = 99; +} + +message TransferUserByAppleRequest { + string token = 1; + string uuid = 2; + string terminalId = 3; +} + +message TransferUserByAppleResponse { + int64 userId = 1; + string signature = 2; + map diffUserData = 99; +} + +message GetUserGamePlayNoteRequest { + int32 gamePlayHistoryTypeId = 1; +} + +message GetUserGamePlayNoteResponse { + int32 progressValue = 1; + map diffUserData = 99; +} \ No newline at end of file diff --git a/server/proto/weapon.proto b/server/proto/weapon.proto new file mode 100644 index 0000000..e982d7e --- /dev/null +++ b/server/proto/weapon.proto @@ -0,0 +1,121 @@ +syntax = "proto3"; + +option go_package = "lunar-tear/server/gen/proto;proto"; + +import "proto/data.proto"; + +package apb.api.weapon; + +service WeaponService { + rpc Sell (SellRequest) returns (SellResponse); + rpc Protect (ProtectRequest) returns (ProtectResponse); + rpc Unprotect (UnprotectRequest) returns (UnprotectResponse); + rpc EnhanceByWeapon (EnhanceByWeaponRequest) returns (EnhanceByWeaponResponse); + rpc EnhanceByMaterial (EnhanceByMaterialRequest) returns (EnhanceByMaterialResponse); + rpc EnhanceSkill (EnhanceSkillRequest) returns (EnhanceSkillResponse); + rpc EnhanceAbility (EnhanceAbilityRequest) returns (EnhanceAbilityResponse); + rpc LimitBreakByWeapon (LimitBreakByWeaponRequest) returns (LimitBreakByWeaponResponse); + rpc LimitBreakByMaterial (LimitBreakByMaterialRequest) returns (LimitBreakByMaterialResponse); + rpc Evolve (EvolveRequest) returns (EvolveResponse); + rpc Awaken (WeaponAwakenRequest) returns (WeaponAwakenResponse); +} + +message SellRequest { + repeated string userWeaponUuid = 1; +} + +message SellResponse { + map diffUserData = 99; +} + +message ProtectRequest { + repeated string userWeaponUuid = 1; +} + +message ProtectResponse { + map diffUserData = 99; +} + +message UnprotectRequest { + repeated string userWeaponUuid = 1; +} + +message UnprotectResponse { + map diffUserData = 99; +} + +message EnhanceByWeaponRequest { + string userWeaponUuid = 1; + repeated string materialUserWeaponUuids = 2; +} + +message EnhanceByWeaponResponse { + bool isGreatSuccess = 1; + repeated string surplusEnhanceWeapon = 2; + map diffUserData = 99; +} + +message EnhanceByMaterialRequest { + string userWeaponUuid = 1; + map materials = 2; +} + +message EnhanceByMaterialResponse { + bool isGreatSuccess = 1; + map surplusEnhanceMaterial = 2; + map diffUserData = 99; +} + +message EnhanceSkillRequest { + string userWeaponUuid = 1; + int32 skillId = 2; + int32 addLevelCount = 3; +} + +message EnhanceSkillResponse { + map diffUserData = 99; +} + +message EnhanceAbilityRequest { + string userWeaponUuid = 1; + int32 abilityId = 2; + int32 addLevelCount = 3; +} + +message EnhanceAbilityResponse { + map diffUserData = 99; +} + +message LimitBreakByWeaponRequest { + string userWeaponUuid = 1; + repeated string materialUserWeaponUuids = 2; +} + +message LimitBreakByWeaponResponse { + map diffUserData = 99; +} + +message LimitBreakByMaterialRequest { + string userWeaponUuid = 1; + map materials = 2; +} + +message LimitBreakByMaterialResponse { + map diffUserData = 99; +} + +message EvolveRequest { + string userWeaponUuid = 1; +} + +message EvolveResponse { + map diffUserData = 99; +} + +message WeaponAwakenRequest { + string userWeaponUuid = 1; +} + +message WeaponAwakenResponse { + map diffUserData = 99; +} \ No newline at end of file