mirror of
https://github.com/Walter-Sparrow/lunar-tear.git
synced 2026-07-02 05:43:41 +03:00
Initial commit
This commit is contained in:
+44
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gacha"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/questflow"
|
||||||
|
"lunar-tear/server/internal/service"
|
||||||
|
"lunar-tear/server/internal/store/memory"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/reflection"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loggingListener struct {
|
||||||
|
net.Listener
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l loggingListener) Accept() (net.Conn, error) {
|
||||||
|
conn, err := l.Listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[gRPC] Accept error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Printf("[gRPC] New connection from %v", conn.RemoteAddr())
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func startGRPC(
|
||||||
|
host string,
|
||||||
|
octoURL string,
|
||||||
|
userStore *memory.MemoryStore,
|
||||||
|
questEngine *questflow.QuestHandler,
|
||||||
|
gachaHandler *gacha.GachaHandler,
|
||||||
|
cageOrnamentCatalog *masterdata.CageOrnamentCatalog,
|
||||||
|
loginBonusCatalog *masterdata.LoginBonusCatalog,
|
||||||
|
characterViewerCatalog *masterdata.CharacterViewerCatalog,
|
||||||
|
shopCatalog *masterdata.ShopCatalog,
|
||||||
|
costumeCatalog *masterdata.CostumeCatalog,
|
||||||
|
omikujiCatalog *masterdata.OmikujiCatalog,
|
||||||
|
weaponCatalog *masterdata.WeaponCatalog,
|
||||||
|
exploreCatalog *masterdata.ExploreCatalog,
|
||||||
|
gimmickCatalog *masterdata.GimmickCatalog,
|
||||||
|
characterBoardCatalog *masterdata.CharacterBoardCatalog,
|
||||||
|
partsCatalog *masterdata.PartsCatalog,
|
||||||
|
characterRebirthCatalog *masterdata.CharacterRebirthCatalog,
|
||||||
|
companionCatalog *masterdata.CompanionCatalog,
|
||||||
|
materialCatalog *masterdata.MaterialCatalog,
|
||||||
|
gameConfig *masterdata.GameConfig,
|
||||||
|
sideStoryCatalog *masterdata.SideStoryCatalog,
|
||||||
|
bigHuntCatalog *masterdata.BigHuntCatalog,
|
||||||
|
) {
|
||||||
|
lis, err := net.Listen("tcp", ":443")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to listen on :443: %v", err)
|
||||||
|
}
|
||||||
|
lis = loggingListener{Listener: lis}
|
||||||
|
|
||||||
|
grpcServer := grpc.NewServer(
|
||||||
|
grpc.ChainUnaryInterceptor(loggingInterceptor, timeSyncInterceptor),
|
||||||
|
grpc.UnknownServiceHandler(loggingUnknownService),
|
||||||
|
)
|
||||||
|
|
||||||
|
registerServices(grpcServer,
|
||||||
|
host,
|
||||||
|
octoURL,
|
||||||
|
userStore,
|
||||||
|
questEngine,
|
||||||
|
gachaHandler,
|
||||||
|
cageOrnamentCatalog,
|
||||||
|
loginBonusCatalog,
|
||||||
|
characterViewerCatalog,
|
||||||
|
shopCatalog,
|
||||||
|
costumeCatalog,
|
||||||
|
omikujiCatalog,
|
||||||
|
weaponCatalog,
|
||||||
|
exploreCatalog,
|
||||||
|
gimmickCatalog,
|
||||||
|
characterBoardCatalog,
|
||||||
|
partsCatalog,
|
||||||
|
characterRebirthCatalog,
|
||||||
|
companionCatalog,
|
||||||
|
materialCatalog,
|
||||||
|
gameConfig,
|
||||||
|
sideStoryCatalog,
|
||||||
|
bigHuntCatalog,
|
||||||
|
)
|
||||||
|
|
||||||
|
reflection.Register(grpcServer)
|
||||||
|
|
||||||
|
log.Printf("gRPC server listening on :443")
|
||||||
|
log.Printf("client host address: %s:443", host)
|
||||||
|
|
||||||
|
if err := grpcServer.Serve(lis); err != nil {
|
||||||
|
log.Fatalf("failed to serve: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerServices(
|
||||||
|
srv *grpc.Server,
|
||||||
|
host string,
|
||||||
|
octoURL string,
|
||||||
|
userStore *memory.MemoryStore,
|
||||||
|
questEngine *questflow.QuestHandler,
|
||||||
|
gachaHandler *gacha.GachaHandler,
|
||||||
|
cageOrnamentCatalog *masterdata.CageOrnamentCatalog,
|
||||||
|
loginBonusCatalog *masterdata.LoginBonusCatalog,
|
||||||
|
characterViewerCatalog *masterdata.CharacterViewerCatalog,
|
||||||
|
shopCatalog *masterdata.ShopCatalog,
|
||||||
|
costumeCatalog *masterdata.CostumeCatalog,
|
||||||
|
omikujiCatalog *masterdata.OmikujiCatalog,
|
||||||
|
weaponCatalog *masterdata.WeaponCatalog,
|
||||||
|
exploreCatalog *masterdata.ExploreCatalog,
|
||||||
|
gimmickCatalog *masterdata.GimmickCatalog,
|
||||||
|
characterBoardCatalog *masterdata.CharacterBoardCatalog,
|
||||||
|
partsCatalog *masterdata.PartsCatalog,
|
||||||
|
characterRebirthCatalog *masterdata.CharacterRebirthCatalog,
|
||||||
|
companionCatalog *masterdata.CompanionCatalog,
|
||||||
|
materialCatalog *masterdata.MaterialCatalog,
|
||||||
|
gameConfig *masterdata.GameConfig,
|
||||||
|
sideStoryCatalog *masterdata.SideStoryCatalog,
|
||||||
|
bigHuntCatalog *masterdata.BigHuntCatalog,
|
||||||
|
) {
|
||||||
|
pb.RegisterBannerServiceServer(srv, service.NewBannerServiceServer(userStore))
|
||||||
|
pb.RegisterUserServiceServer(srv, service.NewUserServiceServer(userStore, userStore))
|
||||||
|
pb.RegisterBattleServiceServer(srv, service.NewBattleServiceServer(userStore, userStore))
|
||||||
|
pb.RegisterConfigServiceServer(srv, service.NewConfigServiceServer(host, int32(443), octoURL))
|
||||||
|
pb.RegisterDataServiceServer(srv, service.NewDataServiceServer(userStore, userStore))
|
||||||
|
pb.RegisterTutorialServiceServer(srv, service.NewTutorialServiceServer(userStore, userStore, questEngine))
|
||||||
|
pb.RegisterGachaServiceServer(srv, service.NewGachaServiceServer(userStore, userStore, userStore, gachaHandler))
|
||||||
|
pb.RegisterGiftServiceServer(srv, service.NewGiftServiceServer(userStore, userStore))
|
||||||
|
pb.RegisterGamePlayServiceServer(srv, service.NewGameplayServiceServer())
|
||||||
|
pb.RegisterGimmickServiceServer(srv, service.NewGimmickServiceServer(userStore, userStore, gimmickCatalog))
|
||||||
|
pb.RegisterQuestServiceServer(srv, service.NewQuestServiceServer(userStore, userStore, questEngine))
|
||||||
|
pb.RegisterNotificationServiceServer(srv, service.NewNotificationServiceServer(userStore, userStore))
|
||||||
|
pb.RegisterCageOrnamentServiceServer(srv, service.NewCageOrnamentServiceServer(userStore, userStore, cageOrnamentCatalog, questEngine.Granter))
|
||||||
|
pb.RegisterDeckServiceServer(srv, service.NewDeckServiceServer(userStore, userStore))
|
||||||
|
pb.RegisterFriendServiceServer(srv, service.NewFriendServiceServer(userStore, userStore))
|
||||||
|
pb.RegisterLoginBonusServiceServer(srv, service.NewLoginBonusServiceServer(userStore, userStore, loginBonusCatalog))
|
||||||
|
pb.RegisterNaviCutInServiceServer(srv, service.NewNaviCutInServiceServer(userStore, userStore))
|
||||||
|
pb.RegisterContentsStoryServiceServer(srv, service.NewContentsStoryServiceServer(userStore, userStore))
|
||||||
|
pb.RegisterDokanServiceServer(srv, service.NewDokanServiceServer(userStore, userStore))
|
||||||
|
pb.RegisterPortalCageServiceServer(srv, service.NewPortalCageServiceServer(userStore, userStore))
|
||||||
|
pb.RegisterCharacterViewerServiceServer(srv, service.NewCharacterViewerServiceServer(userStore, userStore, characterViewerCatalog))
|
||||||
|
pb.RegisterMissionServiceServer(srv, service.NewMissionServiceServer(userStore, userStore))
|
||||||
|
pb.RegisterShopServiceServer(srv, service.NewShopServiceServer(userStore, userStore, shopCatalog, questEngine.Granter))
|
||||||
|
pb.RegisterCostumeServiceServer(srv, service.NewCostumeServiceServer(userStore, userStore, costumeCatalog, gameConfig))
|
||||||
|
pb.RegisterMovieServiceServer(srv, service.NewMovieServiceServer(userStore, userStore))
|
||||||
|
pb.RegisterOmikujiServiceServer(srv, service.NewOmikujiServiceServer(userStore, userStore, omikujiCatalog))
|
||||||
|
pb.RegisterWeaponServiceServer(srv, service.NewWeaponServiceServer(userStore, userStore, weaponCatalog, gameConfig))
|
||||||
|
pb.RegisterExploreServiceServer(srv, service.NewExploreServiceServer(userStore, userStore, exploreCatalog))
|
||||||
|
pb.RegisterCharacterBoardServiceServer(srv, service.NewCharacterBoardServiceServer(userStore, userStore, characterBoardCatalog))
|
||||||
|
pb.RegisterPartsServiceServer(srv, service.NewPartsServiceServer(userStore, userStore, partsCatalog, gameConfig))
|
||||||
|
pb.RegisterCharacterServiceServer(srv, service.NewCharacterServiceServer(userStore, userStore, characterRebirthCatalog, gameConfig))
|
||||||
|
pb.RegisterCompanionServiceServer(srv, service.NewCompanionServiceServer(userStore, userStore, companionCatalog, gameConfig))
|
||||||
|
pb.RegisterMaterialServiceServer(srv, service.NewMaterialServiceServer(userStore, userStore, materialCatalog, gameConfig))
|
||||||
|
pb.RegisterSideStoryQuestServiceServer(srv, service.NewSideStoryQuestServiceServer(userStore, userStore, sideStoryCatalog))
|
||||||
|
pb.RegisterBigHuntServiceServer(srv, service.NewBigHuntServiceServer(userStore, userStore, bigHuntCatalog, questEngine))
|
||||||
|
pb.RegisterRewardServiceServer(srv, service.NewRewardServiceServer(userStore, userStore, bigHuntCatalog, questEngine.Granter))
|
||||||
|
}
|
||||||
|
|
||||||
|
func loggingInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||||
|
log.Printf(">>> %s", info.FullMethod)
|
||||||
|
resp, err := handler(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("<<< %s ERROR: %v", info.FullMethod, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("<<< %s OK", info.FullMethod)
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeSyncInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||||
|
resp, err := handler(ctx, req)
|
||||||
|
switch info.FullMethod {
|
||||||
|
case "/apb.api.user.UserService/Auth",
|
||||||
|
"/apb.api.user.UserService/RegisterUser",
|
||||||
|
"/apb.api.user.UserService/TransferUser":
|
||||||
|
default:
|
||||||
|
grpc.SetTrailer(ctx, metadata.Pairs(
|
||||||
|
"x-apb-response-datetime", fmt.Sprintf("%d", gametime.NowMillis()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loggingUnknownService(_ any, stream grpc.ServerStream) error {
|
||||||
|
fullMethod, ok := grpc.MethodFromServerStream(stream)
|
||||||
|
if !ok {
|
||||||
|
fullMethod = "<unknown>"
|
||||||
|
}
|
||||||
|
log.Printf(">>> %s", fullMethod)
|
||||||
|
err := status.Errorf(codes.Unimplemented, "unknown service or method %s", fullMethod)
|
||||||
|
log.Printf("<<< %s ERROR: %v", fullMethod, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
package masterdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lunar-tear/server/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bigHuntBossQuestRow struct {
|
||||||
|
BigHuntBossQuestId int32 `json:"BigHuntBossQuestId"`
|
||||||
|
BigHuntBossId int32 `json:"BigHuntBossId"`
|
||||||
|
BigHuntQuestGroupId int32 `json:"BigHuntQuestGroupId"`
|
||||||
|
BigHuntBossQuestScoreCoefficientId int32 `json:"BigHuntBossQuestScoreCoefficientId"`
|
||||||
|
BigHuntScoreRewardGroupScheduleId int32 `json:"BigHuntScoreRewardGroupScheduleId"`
|
||||||
|
DailyChallengeCount int32 `json:"DailyChallengeCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BigHuntBossQuestRow struct {
|
||||||
|
BigHuntBossQuestId int32
|
||||||
|
BigHuntBossId int32
|
||||||
|
BigHuntQuestGroupId int32
|
||||||
|
BigHuntScoreRewardGroupScheduleId int32
|
||||||
|
DailyChallengeCount int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type bigHuntQuestRow struct {
|
||||||
|
BigHuntQuestId int32 `json:"BigHuntQuestId"`
|
||||||
|
QuestId int32 `json:"QuestId"`
|
||||||
|
BigHuntQuestScoreCoefficientId int32 `json:"BigHuntQuestScoreCoefficientId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BigHuntQuestRow struct {
|
||||||
|
BigHuntQuestId int32
|
||||||
|
QuestId int32
|
||||||
|
BigHuntQuestScoreCoefficientId int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type bigHuntQuestScoreCoefficientRow struct {
|
||||||
|
BigHuntQuestScoreCoefficientId int32 `json:"BigHuntQuestScoreCoefficientId"`
|
||||||
|
ScoreDifficultBonusPermil int32 `json:"ScoreDifficultBonusPermil"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bigHuntBossRow struct {
|
||||||
|
BigHuntBossId int32 `json:"BigHuntBossId"`
|
||||||
|
BigHuntBossGradeGroupId int32 `json:"BigHuntBossGradeGroupId"`
|
||||||
|
AttributeType int32 `json:"AttributeType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BigHuntBossRow struct {
|
||||||
|
BigHuntBossId int32
|
||||||
|
BigHuntBossGradeGroupId int32
|
||||||
|
AttributeType int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type bigHuntBossGradeGroupRow struct {
|
||||||
|
BigHuntBossGradeGroupId int32 `json:"BigHuntBossGradeGroupId"`
|
||||||
|
NecessaryScore int64 `json:"NecessaryScore"`
|
||||||
|
AssetGradeIconId int32 `json:"AssetGradeIconId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GradeThreshold struct {
|
||||||
|
NecessaryScore int64
|
||||||
|
AssetGradeIconId int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type bigHuntScheduleRow struct {
|
||||||
|
BigHuntScheduleId int32 `json:"BigHuntScheduleId"`
|
||||||
|
ChallengeStartDatetime int64 `json:"ChallengeStartDatetime"`
|
||||||
|
ChallengeEndDatetime int64 `json:"ChallengeEndDatetime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type scoreRewardScheduleRow struct {
|
||||||
|
BigHuntScoreRewardGroupScheduleId int32 `json:"BigHuntScoreRewardGroupScheduleId"`
|
||||||
|
GroupIndex int32 `json:"GroupIndex"`
|
||||||
|
BigHuntScoreRewardGroupId int32 `json:"BigHuntScoreRewardGroupId"`
|
||||||
|
StartDatetime int64 `json:"StartDatetime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreRewardScheduleEntry struct {
|
||||||
|
BigHuntScoreRewardGroupId int32
|
||||||
|
StartDatetime int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type scoreRewardGroupRow struct {
|
||||||
|
BigHuntScoreRewardGroupId int32 `json:"BigHuntScoreRewardGroupId"`
|
||||||
|
NecessaryScore int64 `json:"NecessaryScore"`
|
||||||
|
BigHuntRewardGroupId int32 `json:"BigHuntRewardGroupId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreRewardThreshold struct {
|
||||||
|
NecessaryScore int64
|
||||||
|
BigHuntRewardGroupId int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type rewardGroupRow struct {
|
||||||
|
BigHuntRewardGroupId int32 `json:"BigHuntRewardGroupId"`
|
||||||
|
SortOrder int32 `json:"SortOrder"`
|
||||||
|
PossessionType int32 `json:"PossessionType"`
|
||||||
|
PossessionId int32 `json:"PossessionId"`
|
||||||
|
Count int32 `json:"Count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RewardItem struct {
|
||||||
|
PossessionType int32
|
||||||
|
PossessionId int32
|
||||||
|
Count int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type weeklyRewardScheduleRow struct {
|
||||||
|
BigHuntWeeklyAttributeScoreRewardGroupScheduleId int32 `json:"BigHuntWeeklyAttributeScoreRewardGroupScheduleId"`
|
||||||
|
AttributeType int32 `json:"AttributeType"`
|
||||||
|
GroupIndex int32 `json:"GroupIndex"`
|
||||||
|
BigHuntScoreRewardGroupId int32 `json:"BigHuntScoreRewardGroupId"`
|
||||||
|
StartDatetime int64 `json:"StartDatetime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BigHuntWeeklyRewardKey struct {
|
||||||
|
ScheduleId int32
|
||||||
|
AttributeType int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type BigHuntCatalog struct {
|
||||||
|
BossQuestById map[int32]BigHuntBossQuestRow
|
||||||
|
QuestById map[int32]BigHuntQuestRow
|
||||||
|
ScoreCoefficients map[int32]int32
|
||||||
|
BossByBossId map[int32]BigHuntBossRow
|
||||||
|
GradeThresholds map[int32][]GradeThreshold
|
||||||
|
ActiveScheduleId int32
|
||||||
|
ScoreRewardSchedules map[int32][]ScoreRewardScheduleEntry
|
||||||
|
ScoreRewardThresholds map[int32][]ScoreRewardThreshold
|
||||||
|
RewardItems map[int32][]RewardItem
|
||||||
|
WeeklyRewardSchedules map[BigHuntWeeklyRewardKey][]ScoreRewardScheduleEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BigHuntCatalog) ResolveActiveScoreRewardGroupId(scheduleId int32, nowMillis int64) int32 {
|
||||||
|
entries := c.ScoreRewardSchedules[scheduleId]
|
||||||
|
for _, e := range entries {
|
||||||
|
if nowMillis >= e.StartDatetime {
|
||||||
|
return e.BigHuntScoreRewardGroupId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(entries) > 0 {
|
||||||
|
return entries[len(entries)-1].BigHuntScoreRewardGroupId
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BigHuntCatalog) ResolveActiveWeeklyRewardGroupId(key BigHuntWeeklyRewardKey, nowMillis int64) int32 {
|
||||||
|
entries := c.WeeklyRewardSchedules[key]
|
||||||
|
for _, e := range entries {
|
||||||
|
if nowMillis >= e.StartDatetime {
|
||||||
|
return e.BigHuntScoreRewardGroupId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(entries) > 0 {
|
||||||
|
return entries[len(entries)-1].BigHuntScoreRewardGroupId
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BigHuntCatalog) ResolveGradeIconId(bossId int32, score int64) int32 {
|
||||||
|
boss, ok := c.BossByBossId[bossId]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
thresholds := c.GradeThresholds[boss.BigHuntBossGradeGroupId]
|
||||||
|
var iconId int32
|
||||||
|
for _, t := range thresholds {
|
||||||
|
if score >= t.NecessaryScore {
|
||||||
|
iconId = t.AssetGradeIconId
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return iconId
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BigHuntCatalog) CollectNewRewards(scoreRewardGroupId int32, oldMax, newMax int64) []RewardItem {
|
||||||
|
thresholds := c.ScoreRewardThresholds[scoreRewardGroupId]
|
||||||
|
var items []RewardItem
|
||||||
|
for _, t := range thresholds {
|
||||||
|
if t.NecessaryScore > oldMax && t.NecessaryScore <= newMax {
|
||||||
|
items = append(items, c.RewardItems[t.BigHuntRewardGroupId]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadBigHuntCatalog() *BigHuntCatalog {
|
||||||
|
bossQuestRows, err := utils.ReadJSON[bigHuntBossQuestRow]("EntityMBigHuntBossQuestTable.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load big hunt boss quest table: %v", err)
|
||||||
|
}
|
||||||
|
bossQuestById := make(map[int32]BigHuntBossQuestRow, len(bossQuestRows))
|
||||||
|
for _, r := range bossQuestRows {
|
||||||
|
bossQuestById[r.BigHuntBossQuestId] = BigHuntBossQuestRow{
|
||||||
|
BigHuntBossQuestId: r.BigHuntBossQuestId,
|
||||||
|
BigHuntBossId: r.BigHuntBossId,
|
||||||
|
BigHuntQuestGroupId: r.BigHuntQuestGroupId,
|
||||||
|
BigHuntScoreRewardGroupScheduleId: r.BigHuntScoreRewardGroupScheduleId,
|
||||||
|
DailyChallengeCount: r.DailyChallengeCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
questRows, err := utils.ReadJSON[bigHuntQuestRow]("EntityMBigHuntQuestTable.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load big hunt quest table: %v", err)
|
||||||
|
}
|
||||||
|
questById := make(map[int32]BigHuntQuestRow, len(questRows))
|
||||||
|
for _, r := range questRows {
|
||||||
|
questById[r.BigHuntQuestId] = BigHuntQuestRow{
|
||||||
|
BigHuntQuestId: r.BigHuntQuestId,
|
||||||
|
QuestId: r.QuestId,
|
||||||
|
BigHuntQuestScoreCoefficientId: r.BigHuntQuestScoreCoefficientId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
coeffRows, err := utils.ReadJSON[bigHuntQuestScoreCoefficientRow]("EntityMBigHuntQuestScoreCoefficientTable.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load big hunt quest score coefficient table: %v", err)
|
||||||
|
}
|
||||||
|
scoreCoefficients := make(map[int32]int32, len(coeffRows))
|
||||||
|
for _, r := range coeffRows {
|
||||||
|
scoreCoefficients[r.BigHuntQuestScoreCoefficientId] = r.ScoreDifficultBonusPermil
|
||||||
|
}
|
||||||
|
|
||||||
|
bossRows, err := utils.ReadJSON[bigHuntBossRow]("EntityMBigHuntBossTable.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load big hunt boss table: %v", err)
|
||||||
|
}
|
||||||
|
bossByBossId := make(map[int32]BigHuntBossRow, len(bossRows))
|
||||||
|
for _, r := range bossRows {
|
||||||
|
bossByBossId[r.BigHuntBossId] = BigHuntBossRow{
|
||||||
|
BigHuntBossId: r.BigHuntBossId,
|
||||||
|
BigHuntBossGradeGroupId: r.BigHuntBossGradeGroupId,
|
||||||
|
AttributeType: r.AttributeType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gradeRows, err := utils.ReadJSON[bigHuntBossGradeGroupRow]("EntityMBigHuntBossGradeGroupTable.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load big hunt boss grade group table: %v", err)
|
||||||
|
}
|
||||||
|
gradeThresholds := make(map[int32][]GradeThreshold)
|
||||||
|
for _, r := range gradeRows {
|
||||||
|
gradeThresholds[r.BigHuntBossGradeGroupId] = append(gradeThresholds[r.BigHuntBossGradeGroupId], GradeThreshold{
|
||||||
|
NecessaryScore: r.NecessaryScore,
|
||||||
|
AssetGradeIconId: r.AssetGradeIconId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for k := range gradeThresholds {
|
||||||
|
sort.Slice(gradeThresholds[k], func(i, j int) bool {
|
||||||
|
return gradeThresholds[k][i].NecessaryScore < gradeThresholds[k][j].NecessaryScore
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleRows, err := utils.ReadJSON[bigHuntScheduleRow]("EntityMBigHuntScheduleTable.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load big hunt schedule table: %v", err)
|
||||||
|
}
|
||||||
|
nowMillis := time.Now().UnixMilli()
|
||||||
|
var activeScheduleId int32
|
||||||
|
var latestEndDatetime int64
|
||||||
|
for _, r := range scheduleRows {
|
||||||
|
if nowMillis >= r.ChallengeStartDatetime && nowMillis <= r.ChallengeEndDatetime {
|
||||||
|
activeScheduleId = r.BigHuntScheduleId
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if r.ChallengeEndDatetime > latestEndDatetime {
|
||||||
|
latestEndDatetime = r.ChallengeEndDatetime
|
||||||
|
activeScheduleId = r.BigHuntScheduleId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rewardSchedRows, err := utils.ReadJSON[scoreRewardScheduleRow]("EntityMBigHuntScoreRewardGroupScheduleTable.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load big hunt score reward group schedule table: %v", err)
|
||||||
|
}
|
||||||
|
scoreRewardSchedules := make(map[int32][]ScoreRewardScheduleEntry)
|
||||||
|
for _, r := range rewardSchedRows {
|
||||||
|
scoreRewardSchedules[r.BigHuntScoreRewardGroupScheduleId] = append(
|
||||||
|
scoreRewardSchedules[r.BigHuntScoreRewardGroupScheduleId],
|
||||||
|
ScoreRewardScheduleEntry{
|
||||||
|
BigHuntScoreRewardGroupId: r.BigHuntScoreRewardGroupId,
|
||||||
|
StartDatetime: r.StartDatetime,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for k := range scoreRewardSchedules {
|
||||||
|
sort.Slice(scoreRewardSchedules[k], func(i, j int) bool {
|
||||||
|
return scoreRewardSchedules[k][i].StartDatetime > scoreRewardSchedules[k][j].StartDatetime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
rewardGroupRows, err := utils.ReadJSON[scoreRewardGroupRow]("EntityMBigHuntScoreRewardGroupTable.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load big hunt score reward group table: %v", err)
|
||||||
|
}
|
||||||
|
scoreRewardThresholds := make(map[int32][]ScoreRewardThreshold)
|
||||||
|
for _, r := range rewardGroupRows {
|
||||||
|
scoreRewardThresholds[r.BigHuntScoreRewardGroupId] = append(
|
||||||
|
scoreRewardThresholds[r.BigHuntScoreRewardGroupId],
|
||||||
|
ScoreRewardThreshold{
|
||||||
|
NecessaryScore: r.NecessaryScore,
|
||||||
|
BigHuntRewardGroupId: r.BigHuntRewardGroupId,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for k := range scoreRewardThresholds {
|
||||||
|
sort.Slice(scoreRewardThresholds[k], func(i, j int) bool {
|
||||||
|
return scoreRewardThresholds[k][i].NecessaryScore < scoreRewardThresholds[k][j].NecessaryScore
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
rewardItemRows, err := utils.ReadJSON[rewardGroupRow]("EntityMBigHuntRewardGroupTable.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load big hunt reward group table: %v", err)
|
||||||
|
}
|
||||||
|
rewardItems := make(map[int32][]RewardItem)
|
||||||
|
for _, r := range rewardItemRows {
|
||||||
|
rewardItems[r.BigHuntRewardGroupId] = append(rewardItems[r.BigHuntRewardGroupId], RewardItem{
|
||||||
|
PossessionType: r.PossessionType,
|
||||||
|
PossessionId: r.PossessionId,
|
||||||
|
Count: r.Count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
weeklySchedRows, err := utils.ReadJSON[weeklyRewardScheduleRow]("EntityMBigHuntWeeklyAttributeScoreRewardGroupScheduleTable.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load big hunt weekly attribute score reward group schedule table: %v", err)
|
||||||
|
}
|
||||||
|
weeklyRewardSchedules := make(map[BigHuntWeeklyRewardKey][]ScoreRewardScheduleEntry)
|
||||||
|
for _, r := range weeklySchedRows {
|
||||||
|
key := BigHuntWeeklyRewardKey{
|
||||||
|
ScheduleId: r.BigHuntWeeklyAttributeScoreRewardGroupScheduleId,
|
||||||
|
AttributeType: r.AttributeType,
|
||||||
|
}
|
||||||
|
weeklyRewardSchedules[key] = append(weeklyRewardSchedules[key], ScoreRewardScheduleEntry{
|
||||||
|
BigHuntScoreRewardGroupId: r.BigHuntScoreRewardGroupId,
|
||||||
|
StartDatetime: r.StartDatetime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for k := range weeklyRewardSchedules {
|
||||||
|
sort.Slice(weeklyRewardSchedules[k], func(i, j int) bool {
|
||||||
|
return weeklyRewardSchedules[k][i].StartDatetime > weeklyRewardSchedules[k][j].StartDatetime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("big hunt catalog loaded: %d boss quests, %d quests, %d bosses, %d score coefficients, %d reward groups, schedule=%d",
|
||||||
|
len(bossQuestById), len(questById), len(bossByBossId), len(scoreCoefficients), len(rewardItems), activeScheduleId)
|
||||||
|
|
||||||
|
return &BigHuntCatalog{
|
||||||
|
BossQuestById: bossQuestById,
|
||||||
|
QuestById: questById,
|
||||||
|
ScoreCoefficients: scoreCoefficients,
|
||||||
|
BossByBossId: bossByBossId,
|
||||||
|
GradeThresholds: gradeThresholds,
|
||||||
|
ActiveScheduleId: activeScheduleId,
|
||||||
|
ScoreRewardSchedules: scoreRewardSchedules,
|
||||||
|
ScoreRewardThresholds: scoreRewardThresholds,
|
||||||
|
RewardItems: rewardItems,
|
||||||
|
WeeklyRewardSchedules: weeklyRewardSchedules,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package masterdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"lunar-tear/server/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cageOrnament struct {
|
||||||
|
CageOrnamentId int32 `json:"CageOrnamentId"`
|
||||||
|
CageOrnamentRewardId int32 `json:"CageOrnamentRewardId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cageOrnamentRewardRow struct {
|
||||||
|
CageOrnamentRewardId int32 `json:"CageOrnamentRewardId"`
|
||||||
|
PossessionType int32 `json:"PossessionType"`
|
||||||
|
PossessionId int32 `json:"PossessionId"`
|
||||||
|
Count int32 `json:"Count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CageOrnamentReward struct {
|
||||||
|
PossessionType int32
|
||||||
|
PossessionId int32
|
||||||
|
Count int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type CageOrnamentCatalog struct {
|
||||||
|
ornamentToRewardId map[int32]int32
|
||||||
|
rewards map[int32]CageOrnamentReward
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CageOrnamentCatalog) LookupReward(cageOrnamentId int32) (CageOrnamentReward, bool) {
|
||||||
|
rewardId, ok := c.ornamentToRewardId[cageOrnamentId]
|
||||||
|
if !ok || rewardId == 0 {
|
||||||
|
return CageOrnamentReward{}, false
|
||||||
|
}
|
||||||
|
entry, ok := c.rewards[rewardId]
|
||||||
|
return entry, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadCageOrnamentCatalog() *CageOrnamentCatalog {
|
||||||
|
ornaments, err := utils.ReadJSON[cageOrnament]("EntityMCageOrnamentTable.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load cage ornament table: %v", err)
|
||||||
|
}
|
||||||
|
rewards, err := utils.ReadJSON[cageOrnamentRewardRow]("EntityMCageOrnamentRewardTable.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load cage ornament reward table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cat := &CageOrnamentCatalog{
|
||||||
|
ornamentToRewardId: make(map[int32]int32, len(ornaments)),
|
||||||
|
rewards: make(map[int32]CageOrnamentReward, len(rewards)),
|
||||||
|
}
|
||||||
|
for _, o := range ornaments {
|
||||||
|
cat.ornamentToRewardId[o.CageOrnamentId] = o.CageOrnamentRewardId
|
||||||
|
}
|
||||||
|
for _, r := range rewards {
|
||||||
|
cat.rewards[r.CageOrnamentRewardId] = CageOrnamentReward{
|
||||||
|
PossessionType: r.PossessionType,
|
||||||
|
PossessionId: r.PossessionId,
|
||||||
|
Count: r.Count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cat
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package masterdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"lunar-tear/server/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CharacterRebirthRow struct {
|
||||||
|
CharacterId int32 `json:"CharacterId"`
|
||||||
|
CharacterRebirthStepGroupId int32 `json:"CharacterRebirthStepGroupId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterRebirthStepRow struct {
|
||||||
|
CharacterRebirthStepGroupId int32 `json:"CharacterRebirthStepGroupId"`
|
||||||
|
BeforeRebirthCount int32 `json:"BeforeRebirthCount"`
|
||||||
|
CostumeLevelLimitUp int32 `json:"CostumeLevelLimitUp"`
|
||||||
|
CharacterRebirthMaterialGroupId int32 `json:"CharacterRebirthMaterialGroupId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterRebirthMaterialRow struct {
|
||||||
|
CharacterRebirthMaterialGroupId int32 `json:"CharacterRebirthMaterialGroupId"`
|
||||||
|
MaterialId int32 `json:"MaterialId"`
|
||||||
|
Count int32 `json:"Count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepKey struct {
|
||||||
|
GroupId int32
|
||||||
|
BeforeRebirthCount int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterRebirthCatalog struct {
|
||||||
|
StepGroupByCharacterId map[int32]int32
|
||||||
|
StepByGroupAndCount map[StepKey]CharacterRebirthStepRow
|
||||||
|
MaterialsByGroupId map[int32][]CharacterRebirthMaterialRow
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadCharacterRebirthCatalog() (*CharacterRebirthCatalog, error) {
|
||||||
|
rebirthRows, err := utils.ReadJSON[CharacterRebirthRow]("EntityMCharacterRebirthTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load character rebirth table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stepRows, err := utils.ReadJSON[CharacterRebirthStepRow]("EntityMCharacterRebirthStepGroupTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load character rebirth step group table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
materialRows, err := utils.ReadJSON[CharacterRebirthMaterialRow]("EntityMCharacterRebirthMaterialGroupTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load character rebirth material group table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stepGroupByCharacterId := make(map[int32]int32, len(rebirthRows))
|
||||||
|
for _, r := range rebirthRows {
|
||||||
|
stepGroupByCharacterId[r.CharacterId] = r.CharacterRebirthStepGroupId
|
||||||
|
}
|
||||||
|
|
||||||
|
stepByGroupAndCount := make(map[StepKey]CharacterRebirthStepRow, len(stepRows))
|
||||||
|
for _, s := range stepRows {
|
||||||
|
stepByGroupAndCount[StepKey{GroupId: s.CharacterRebirthStepGroupId, BeforeRebirthCount: s.BeforeRebirthCount}] = s
|
||||||
|
}
|
||||||
|
|
||||||
|
materialsByGroupId := make(map[int32][]CharacterRebirthMaterialRow)
|
||||||
|
for _, m := range materialRows {
|
||||||
|
materialsByGroupId[m.CharacterRebirthMaterialGroupId] = append(materialsByGroupId[m.CharacterRebirthMaterialGroupId], m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CharacterRebirthCatalog{
|
||||||
|
StepGroupByCharacterId: stepGroupByCharacterId,
|
||||||
|
StepByGroupAndCount: stepByGroupAndCount,
|
||||||
|
MaterialsByGroupId: materialsByGroupId,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package masterdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CharacterBoardPanelRow struct {
|
||||||
|
CharacterBoardPanelId int32 `json:"CharacterBoardPanelId"`
|
||||||
|
CharacterBoardId int32 `json:"CharacterBoardId"`
|
||||||
|
CharacterBoardPanelUnlockConditionGroupId int32 `json:"CharacterBoardPanelUnlockConditionGroupId"`
|
||||||
|
CharacterBoardPanelReleasePossessionGroupId int32 `json:"CharacterBoardPanelReleasePossessionGroupId"`
|
||||||
|
CharacterBoardPanelReleaseRewardGroupId int32 `json:"CharacterBoardPanelReleaseRewardGroupId"`
|
||||||
|
CharacterBoardPanelReleaseEffectGroupId int32 `json:"CharacterBoardPanelReleaseEffectGroupId"`
|
||||||
|
SortOrder int32 `json:"SortOrder"`
|
||||||
|
ParentCharacterBoardPanelId int32 `json:"ParentCharacterBoardPanelId"`
|
||||||
|
PlaceIndex int32 `json:"PlaceIndex"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterBoardReleasePossessionRow struct {
|
||||||
|
CharacterBoardPanelReleasePossessionGroupId int32 `json:"CharacterBoardPanelReleasePossessionGroupId"`
|
||||||
|
PossessionType int32 `json:"PossessionType"`
|
||||||
|
PossessionId int32 `json:"PossessionId"`
|
||||||
|
Count int32 `json:"Count"`
|
||||||
|
SortOrder int32 `json:"SortOrder"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterBoardReleaseEffectRow struct {
|
||||||
|
CharacterBoardPanelReleaseEffectGroupId int32 `json:"CharacterBoardPanelReleaseEffectGroupId"`
|
||||||
|
SortOrder int32 `json:"SortOrder"`
|
||||||
|
CharacterBoardEffectType int32 `json:"CharacterBoardEffectType"`
|
||||||
|
CharacterBoardEffectId int32 `json:"CharacterBoardEffectId"`
|
||||||
|
EffectValue int32 `json:"EffectValue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterBoardRow struct {
|
||||||
|
CharacterBoardId int32 `json:"CharacterBoardId"`
|
||||||
|
CharacterBoardGroupId int32 `json:"CharacterBoardGroupId"`
|
||||||
|
CharacterBoardUnlockConditionGroupId int32 `json:"CharacterBoardUnlockConditionGroupId"`
|
||||||
|
ReleaseRank int32 `json:"ReleaseRank"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterBoardStatusUpRow struct {
|
||||||
|
CharacterBoardStatusUpId int32 `json:"CharacterBoardStatusUpId"`
|
||||||
|
CharacterBoardStatusUpType int32 `json:"CharacterBoardStatusUpType"`
|
||||||
|
CharacterBoardEffectTargetGroupId int32 `json:"CharacterBoardEffectTargetGroupId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterBoardAbilityRow struct {
|
||||||
|
CharacterBoardAbilityId int32 `json:"CharacterBoardAbilityId"`
|
||||||
|
CharacterBoardEffectTargetGroupId int32 `json:"CharacterBoardEffectTargetGroupId"`
|
||||||
|
AbilityId int32 `json:"AbilityId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterBoardAbilityMaxLevelRow struct {
|
||||||
|
CharacterId int32 `json:"CharacterId"`
|
||||||
|
AbilityId int32 `json:"AbilityId"`
|
||||||
|
MaxLevel int32 `json:"MaxLevel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterBoardEffectTargetRow struct {
|
||||||
|
CharacterBoardEffectTargetGroupId int32 `json:"CharacterBoardEffectTargetGroupId"`
|
||||||
|
GroupIndex int32 `json:"GroupIndex"`
|
||||||
|
CharacterBoardEffectTargetType int32 `json:"CharacterBoardEffectTargetType"`
|
||||||
|
TargetValue int32 `json:"TargetValue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterBoardAssignmentRow struct {
|
||||||
|
CharacterId int32 `json:"CharacterId"`
|
||||||
|
CharacterBoardCategoryId int32 `json:"CharacterBoardCategoryId"`
|
||||||
|
SortOrder int32 `json:"SortOrder"`
|
||||||
|
CharacterBoardAssignmentType int32 `json:"CharacterBoardAssignmentType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterBoardGroupRow struct {
|
||||||
|
CharacterBoardGroupId int32 `json:"CharacterBoardGroupId"`
|
||||||
|
CharacterBoardCategoryId int32 `json:"CharacterBoardCategoryId"`
|
||||||
|
SortOrder int32 `json:"SortOrder"`
|
||||||
|
CharacterBoardGroupType int32 `json:"CharacterBoardGroupType"`
|
||||||
|
TextAssetId int32 `json:"TextAssetId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterBoardCatalog struct {
|
||||||
|
PanelById map[int32]CharacterBoardPanelRow
|
||||||
|
PanelsByBoardId map[int32][]CharacterBoardPanelRow
|
||||||
|
ReleaseCostsByGroupId map[int32][]CharacterBoardReleasePossessionRow
|
||||||
|
ReleaseEffectsByGroupId map[int32][]CharacterBoardReleaseEffectRow
|
||||||
|
StatusUpById map[int32]CharacterBoardStatusUpRow
|
||||||
|
AbilityById map[int32]CharacterBoardAbilityRow
|
||||||
|
AbilityMaxLevel map[store.CharacterBoardAbilityKey]int32
|
||||||
|
EffectTargetsByGroupId map[int32][]CharacterBoardEffectTargetRow
|
||||||
|
BoardById map[int32]CharacterBoardRow
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadCharacterBoardCatalog() (*CharacterBoardCatalog, error) {
|
||||||
|
panels, err := utils.ReadJSON[CharacterBoardPanelRow]("EntityMCharacterBoardPanelTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load character board panel table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
costs, err := utils.ReadJSON[CharacterBoardReleasePossessionRow]("EntityMCharacterBoardPanelReleasePossessionGroupTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load character board release possession table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
effects, err := utils.ReadJSON[CharacterBoardReleaseEffectRow]("EntityMCharacterBoardPanelReleaseEffectGroupTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load character board release effect table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
boards, err := utils.ReadJSON[CharacterBoardRow]("EntityMCharacterBoardTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load character board table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusUps, err := utils.ReadJSON[CharacterBoardStatusUpRow]("EntityMCharacterBoardStatusUpTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load character board status up table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
abilities, err := utils.ReadJSON[CharacterBoardAbilityRow]("EntityMCharacterBoardAbilityTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load character board ability table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
abilityMaxLevels, err := utils.ReadJSON[CharacterBoardAbilityMaxLevelRow]("EntityMCharacterBoardAbilityMaxLevelTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load character board ability max level table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targets, err := utils.ReadJSON[CharacterBoardEffectTargetRow]("EntityMCharacterBoardEffectTargetGroupTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load character board effect target table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog := &CharacterBoardCatalog{
|
||||||
|
PanelById: make(map[int32]CharacterBoardPanelRow, len(panels)),
|
||||||
|
PanelsByBoardId: make(map[int32][]CharacterBoardPanelRow),
|
||||||
|
ReleaseCostsByGroupId: make(map[int32][]CharacterBoardReleasePossessionRow),
|
||||||
|
ReleaseEffectsByGroupId: make(map[int32][]CharacterBoardReleaseEffectRow),
|
||||||
|
StatusUpById: make(map[int32]CharacterBoardStatusUpRow, len(statusUps)),
|
||||||
|
AbilityById: make(map[int32]CharacterBoardAbilityRow, len(abilities)),
|
||||||
|
AbilityMaxLevel: make(map[store.CharacterBoardAbilityKey]int32, len(abilityMaxLevels)),
|
||||||
|
EffectTargetsByGroupId: make(map[int32][]CharacterBoardEffectTargetRow),
|
||||||
|
BoardById: make(map[int32]CharacterBoardRow, len(boards)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range panels {
|
||||||
|
catalog.PanelById[p.CharacterBoardPanelId] = p
|
||||||
|
catalog.PanelsByBoardId[p.CharacterBoardId] = append(catalog.PanelsByBoardId[p.CharacterBoardId], p)
|
||||||
|
}
|
||||||
|
for _, c := range costs {
|
||||||
|
catalog.ReleaseCostsByGroupId[c.CharacterBoardPanelReleasePossessionGroupId] = append(
|
||||||
|
catalog.ReleaseCostsByGroupId[c.CharacterBoardPanelReleasePossessionGroupId], c)
|
||||||
|
}
|
||||||
|
for _, e := range effects {
|
||||||
|
catalog.ReleaseEffectsByGroupId[e.CharacterBoardPanelReleaseEffectGroupId] = append(
|
||||||
|
catalog.ReleaseEffectsByGroupId[e.CharacterBoardPanelReleaseEffectGroupId], e)
|
||||||
|
}
|
||||||
|
for _, b := range boards {
|
||||||
|
catalog.BoardById[b.CharacterBoardId] = b
|
||||||
|
}
|
||||||
|
for _, s := range statusUps {
|
||||||
|
catalog.StatusUpById[s.CharacterBoardStatusUpId] = s
|
||||||
|
}
|
||||||
|
for _, a := range abilities {
|
||||||
|
catalog.AbilityById[a.CharacterBoardAbilityId] = a
|
||||||
|
}
|
||||||
|
for _, m := range abilityMaxLevels {
|
||||||
|
catalog.AbilityMaxLevel[store.CharacterBoardAbilityKey{
|
||||||
|
CharacterId: m.CharacterId,
|
||||||
|
AbilityId: m.AbilityId,
|
||||||
|
}] = m.MaxLevel
|
||||||
|
}
|
||||||
|
for _, t := range targets {
|
||||||
|
catalog.EffectTargetsByGroupId[t.CharacterBoardEffectTargetGroupId] = append(
|
||||||
|
catalog.EffectTargetsByGroupId[t.CharacterBoardEffectTargetGroupId], t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return catalog, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package masterdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type characterViewerField struct {
|
||||||
|
CharacterViewerFieldId int32 `json:"CharacterViewerFieldId"`
|
||||||
|
ReleaseEvaluateConditionId int32 `json:"ReleaseEvaluateConditionId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type characterViewerFieldEntry struct {
|
||||||
|
FieldId int32
|
||||||
|
RequiredQuestId int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterViewerCatalog struct {
|
||||||
|
fields []characterViewerFieldEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CharacterViewerCatalog) ReleasedFieldIds(user store.UserState) []int32 {
|
||||||
|
var released []int32
|
||||||
|
for _, f := range c.fields {
|
||||||
|
if f.RequiredQuestId == 0 {
|
||||||
|
released = append(released, f.FieldId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
q, ok := user.Quests[f.RequiredQuestId]
|
||||||
|
if ok && q.QuestStateType == model.UserQuestStateTypeCleared {
|
||||||
|
released = append(released, f.FieldId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return released
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadCharacterViewerCatalog(resolver *ConditionResolver) *CharacterViewerCatalog {
|
||||||
|
fields, err := utils.ReadJSON[characterViewerField]("EntityMCharacterViewerFieldTable.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load character viewer field table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cat := &CharacterViewerCatalog{}
|
||||||
|
for _, f := range fields {
|
||||||
|
entry := characterViewerFieldEntry{FieldId: f.CharacterViewerFieldId}
|
||||||
|
if qid, ok := resolver.RequiredQuestId(f.ReleaseEvaluateConditionId); ok {
|
||||||
|
entry.RequiredQuestId = qid
|
||||||
|
}
|
||||||
|
cat.fields = append(cat.fields, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(cat.fields, func(i, j int) bool {
|
||||||
|
return cat.fields[i].FieldId < cat.fields[j].FieldId
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("character viewer catalog loaded: %d fields", len(cat.fields))
|
||||||
|
return cat
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package masterdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CostumeMasterRow struct {
|
||||||
|
CostumeId int32 `json:"CostumeId"`
|
||||||
|
CharacterId int32 `json:"CharacterId"`
|
||||||
|
SkillfulWeaponType int32 `json:"SkillfulWeaponType"`
|
||||||
|
RarityType int32 `json:"RarityType"`
|
||||||
|
CostumeLimitBreakMaterialGroupId int32 `json:"CostumeLimitBreakMaterialGroupId"`
|
||||||
|
CostumeActiveSkillGroupId int32 `json:"CostumeActiveSkillGroupId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type costumeRarityRow struct {
|
||||||
|
RarityType int32 `json:"RarityType"`
|
||||||
|
CostumeLimitBreakMaterialRarityGroupId int32 `json:"CostumeLimitBreakMaterialRarityGroupId"`
|
||||||
|
RequiredExpForLevelUpNumericalParameterMapId int32 `json:"RequiredExpForLevelUpNumericalParameterMapId"`
|
||||||
|
EnhancementCostByMaterialNumericalFunctionId int32 `json:"EnhancementCostByMaterialNumericalFunctionId"`
|
||||||
|
LimitBreakCostNumericalFunctionId int32 `json:"LimitBreakCostNumericalFunctionId"`
|
||||||
|
MaxLevelNumericalFunctionId int32 `json:"MaxLevelNumericalFunctionId"`
|
||||||
|
ActiveSkillMaxLevelNumericalFunctionId int32 `json:"ActiveSkillMaxLevelNumericalFunctionId"`
|
||||||
|
ActiveSkillEnhancementCostNumericalFunctionId int32 `json:"ActiveSkillEnhancementCostNumericalFunctionId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CostumeAwakenRow struct {
|
||||||
|
CostumeId int32 `json:"CostumeId"`
|
||||||
|
CostumeAwakenEffectGroupId int32 `json:"CostumeAwakenEffectGroupId"`
|
||||||
|
CostumeAwakenStepMaterialGroupId int32 `json:"CostumeAwakenStepMaterialGroupId"`
|
||||||
|
CostumeAwakenPriceGroupId int32 `json:"CostumeAwakenPriceGroupId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type costumeAwakenPriceRow struct {
|
||||||
|
CostumeAwakenPriceGroupId int32 `json:"CostumeAwakenPriceGroupId"`
|
||||||
|
AwakenStepLowerLimit int32 `json:"AwakenStepLowerLimit"`
|
||||||
|
Gold int32 `json:"Gold"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CostumeAwakenEffectRow struct {
|
||||||
|
CostumeAwakenEffectGroupId int32 `json:"CostumeAwakenEffectGroupId"`
|
||||||
|
AwakenStep int32 `json:"AwakenStep"`
|
||||||
|
CostumeAwakenEffectType int32 `json:"CostumeAwakenEffectType"`
|
||||||
|
CostumeAwakenEffectId int32 `json:"CostumeAwakenEffectId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CostumeAwakenStatusUpRow struct {
|
||||||
|
CostumeAwakenStatusUpGroupId int32 `json:"CostumeAwakenStatusUpGroupId"`
|
||||||
|
SortOrder int32 `json:"SortOrder"`
|
||||||
|
StatusKindType int32 `json:"StatusKindType"`
|
||||||
|
StatusCalculationType int32 `json:"StatusCalculationType"`
|
||||||
|
EffectValue int32 `json:"EffectValue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CostumeAwakenItemAcquireRow struct {
|
||||||
|
CostumeAwakenItemAcquireId int32 `json:"CostumeAwakenItemAcquireId"`
|
||||||
|
PossessionType int32 `json:"PossessionType"`
|
||||||
|
PossessionId int32 `json:"PossessionId"`
|
||||||
|
Count int32 `json:"Count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CostumeActiveSkillGroupRow struct {
|
||||||
|
CostumeActiveSkillGroupId int32 `json:"CostumeActiveSkillGroupId"`
|
||||||
|
CostumeLimitBreakCountLowerLimit int32 `json:"CostumeLimitBreakCountLowerLimit"`
|
||||||
|
CostumeActiveSkillId int32 `json:"CostumeActiveSkillId"`
|
||||||
|
CostumeActiveSkillEnhancementMaterialId int32 `json:"CostumeActiveSkillEnhancementMaterialId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CostumeActiveSkillEnhanceMaterialRow struct {
|
||||||
|
CostumeActiveSkillEnhancementMaterialId int32 `json:"CostumeActiveSkillEnhancementMaterialId"`
|
||||||
|
SkillLevel int32 `json:"SkillLevel"`
|
||||||
|
MaterialId int32 `json:"MaterialId"`
|
||||||
|
Count int32 `json:"Count"`
|
||||||
|
SortOrder int32 `json:"SortOrder"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CostumeCatalog struct {
|
||||||
|
Costumes map[int32]CostumeMasterRow
|
||||||
|
Materials map[int32]MaterialRow
|
||||||
|
ExpByRarity map[int32][]int32
|
||||||
|
EnhanceCostByRarity map[int32]NumericalFunc
|
||||||
|
MaxLevelByRarity map[int32]NumericalFunc
|
||||||
|
LimitBreakCostByRarity map[int32]NumericalFunc
|
||||||
|
|
||||||
|
AwakenByCostumeId map[int32]CostumeAwakenRow
|
||||||
|
AwakenPriceByGroup map[int32]int32
|
||||||
|
AwakenEffectsByGroupAndStep map[int32]map[int32]CostumeAwakenEffectRow
|
||||||
|
AwakenStatusUpByGroup map[int32][]CostumeAwakenStatusUpRow
|
||||||
|
AwakenItemAcquireById map[int32]CostumeAwakenItemAcquireRow
|
||||||
|
|
||||||
|
ActiveSkillGroupsByGroupId map[int32][]CostumeActiveSkillGroupRow // sorted by CostumeLimitBreakCountLowerLimit desc
|
||||||
|
ActiveSkillEnhanceMats map[[2]int32][]CostumeActiveSkillEnhanceMaterialRow // key: [enhancementMaterialId, skillLevel]
|
||||||
|
ActiveSkillMaxLevelByRarity map[int32]NumericalFunc
|
||||||
|
ActiveSkillCostByRarity map[int32]NumericalFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadCostumeCatalog(matCatalog *MaterialCatalog) (*CostumeCatalog, error) {
|
||||||
|
costumes, err := utils.ReadJSON[CostumeMasterRow]("EntityMCostumeTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load costume table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rarities, err := utils.ReadJSON[costumeRarityRow]("EntityMCostumeRarityTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load costume rarity table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
paramMapRows, err := LoadParameterMap()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
funcResolver, err := LoadFunctionResolver()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load function resolver: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
awakenRows, err := utils.ReadJSON[CostumeAwakenRow]("EntityMCostumeAwakenTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load costume awaken table: %w", err)
|
||||||
|
}
|
||||||
|
awakenPriceRows, err := utils.ReadJSON[costumeAwakenPriceRow]("EntityMCostumeAwakenPriceGroupTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load costume awaken price table: %w", err)
|
||||||
|
}
|
||||||
|
awakenEffectRows, err := utils.ReadJSON[CostumeAwakenEffectRow]("EntityMCostumeAwakenEffectGroupTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load costume awaken effect table: %w", err)
|
||||||
|
}
|
||||||
|
awakenStatusUpRows, err := utils.ReadJSON[CostumeAwakenStatusUpRow]("EntityMCostumeAwakenStatusUpGroupTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load costume awaken status up table: %w", err)
|
||||||
|
}
|
||||||
|
awakenItemAcquireRows, err := utils.ReadJSON[CostumeAwakenItemAcquireRow]("EntityMCostumeAwakenItemAcquireTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load costume awaken item acquire table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSkillGroupRows, err := utils.ReadJSON[CostumeActiveSkillGroupRow]("EntityMCostumeActiveSkillGroupTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load costume active skill group table: %w", err)
|
||||||
|
}
|
||||||
|
activeSkillMatRows, err := utils.ReadJSON[CostumeActiveSkillEnhanceMaterialRow]("EntityMCostumeActiveSkillEnhancementMaterialTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load costume active skill enhancement material table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog := &CostumeCatalog{
|
||||||
|
Costumes: make(map[int32]CostumeMasterRow, len(costumes)),
|
||||||
|
Materials: matCatalog.ByType[model.MaterialTypeCostumeEnhancement],
|
||||||
|
ExpByRarity: make(map[int32][]int32, len(rarities)),
|
||||||
|
EnhanceCostByRarity: make(map[int32]NumericalFunc, len(rarities)),
|
||||||
|
MaxLevelByRarity: make(map[int32]NumericalFunc, len(rarities)),
|
||||||
|
LimitBreakCostByRarity: make(map[int32]NumericalFunc, len(rarities)),
|
||||||
|
|
||||||
|
AwakenByCostumeId: make(map[int32]CostumeAwakenRow, len(awakenRows)),
|
||||||
|
AwakenPriceByGroup: make(map[int32]int32, len(awakenPriceRows)),
|
||||||
|
AwakenEffectsByGroupAndStep: make(map[int32]map[int32]CostumeAwakenEffectRow),
|
||||||
|
AwakenStatusUpByGroup: make(map[int32][]CostumeAwakenStatusUpRow),
|
||||||
|
AwakenItemAcquireById: make(map[int32]CostumeAwakenItemAcquireRow, len(awakenItemAcquireRows)),
|
||||||
|
|
||||||
|
ActiveSkillGroupsByGroupId: make(map[int32][]CostumeActiveSkillGroupRow),
|
||||||
|
ActiveSkillEnhanceMats: make(map[[2]int32][]CostumeActiveSkillEnhanceMaterialRow),
|
||||||
|
ActiveSkillMaxLevelByRarity: make(map[int32]NumericalFunc, len(rarities)),
|
||||||
|
ActiveSkillCostByRarity: make(map[int32]NumericalFunc, len(rarities)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range costumes {
|
||||||
|
catalog.Costumes[row.CostumeId] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range rarities {
|
||||||
|
if _, ok := catalog.ExpByRarity[r.RarityType]; !ok {
|
||||||
|
catalog.ExpByRarity[r.RarityType] = BuildExpThresholds(paramMapRows, r.RequiredExpForLevelUpNumericalParameterMapId)
|
||||||
|
}
|
||||||
|
if _, ok := catalog.EnhanceCostByRarity[r.RarityType]; !ok {
|
||||||
|
if f, found := funcResolver.Resolve(r.EnhancementCostByMaterialNumericalFunctionId); found {
|
||||||
|
catalog.EnhanceCostByRarity[r.RarityType] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := catalog.MaxLevelByRarity[r.RarityType]; !ok {
|
||||||
|
if f, found := funcResolver.Resolve(r.MaxLevelNumericalFunctionId); found {
|
||||||
|
catalog.MaxLevelByRarity[r.RarityType] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := catalog.LimitBreakCostByRarity[r.RarityType]; !ok {
|
||||||
|
if f, found := funcResolver.Resolve(r.LimitBreakCostNumericalFunctionId); found {
|
||||||
|
catalog.LimitBreakCostByRarity[r.RarityType] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := catalog.ActiveSkillMaxLevelByRarity[r.RarityType]; !ok {
|
||||||
|
if f, found := funcResolver.Resolve(r.ActiveSkillMaxLevelNumericalFunctionId); found {
|
||||||
|
catalog.ActiveSkillMaxLevelByRarity[r.RarityType] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := catalog.ActiveSkillCostByRarity[r.RarityType]; !ok {
|
||||||
|
if f, found := funcResolver.Resolve(r.ActiveSkillEnhancementCostNumericalFunctionId); found {
|
||||||
|
catalog.ActiveSkillCostByRarity[r.RarityType] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range awakenRows {
|
||||||
|
catalog.AwakenByCostumeId[row.CostumeId] = row
|
||||||
|
}
|
||||||
|
for _, row := range awakenPriceRows {
|
||||||
|
catalog.AwakenPriceByGroup[row.CostumeAwakenPriceGroupId] = row.Gold
|
||||||
|
}
|
||||||
|
for _, row := range awakenEffectRows {
|
||||||
|
m, ok := catalog.AwakenEffectsByGroupAndStep[row.CostumeAwakenEffectGroupId]
|
||||||
|
if !ok {
|
||||||
|
m = make(map[int32]CostumeAwakenEffectRow)
|
||||||
|
catalog.AwakenEffectsByGroupAndStep[row.CostumeAwakenEffectGroupId] = m
|
||||||
|
}
|
||||||
|
m[row.AwakenStep] = row
|
||||||
|
}
|
||||||
|
for _, row := range awakenStatusUpRows {
|
||||||
|
catalog.AwakenStatusUpByGroup[row.CostumeAwakenStatusUpGroupId] = append(
|
||||||
|
catalog.AwakenStatusUpByGroup[row.CostumeAwakenStatusUpGroupId], row)
|
||||||
|
}
|
||||||
|
for _, row := range awakenItemAcquireRows {
|
||||||
|
catalog.AwakenItemAcquireById[row.CostumeAwakenItemAcquireId] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range activeSkillGroupRows {
|
||||||
|
gid := row.CostumeActiveSkillGroupId
|
||||||
|
catalog.ActiveSkillGroupsByGroupId[gid] = append(catalog.ActiveSkillGroupsByGroupId[gid], row)
|
||||||
|
}
|
||||||
|
for gid, rows := range catalog.ActiveSkillGroupsByGroupId {
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
return rows[i].CostumeLimitBreakCountLowerLimit > rows[j].CostumeLimitBreakCountLowerLimit
|
||||||
|
})
|
||||||
|
catalog.ActiveSkillGroupsByGroupId[gid] = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range activeSkillMatRows {
|
||||||
|
key := [2]int32{row.CostumeActiveSkillEnhancementMaterialId, row.SkillLevel}
|
||||||
|
catalog.ActiveSkillEnhanceMats[key] = append(catalog.ActiveSkillEnhanceMats[key], row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return catalog, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package masterdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type costumeDupRow struct {
|
||||||
|
CostumeId int32 `json:"CostumeId"`
|
||||||
|
PossessionType int32 `json:"PossessionType"`
|
||||||
|
PossessionId int32 `json:"PossessionId"`
|
||||||
|
Count int32 `json:"Count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadDupExchange() (map[int32][]model.DupExchangeEntry, error) {
|
||||||
|
result := make(map[int32][]model.DupExchangeEntry)
|
||||||
|
|
||||||
|
costumeRows, err := utils.ReadJSON[costumeDupRow]("EntityMCostumeDuplicationExchangePossessionGroupTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range costumeRows {
|
||||||
|
result[r.CostumeId] = append(result[r.CostumeId], model.DupExchangeEntry{
|
||||||
|
PossessionType: r.PossessionType,
|
||||||
|
PossessionId: r.PossessionId,
|
||||||
|
Count: r.Count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type lbMaterialRow struct {
|
||||||
|
CostumeLimitBreakMaterialGroupId int32 `json:"CostumeLimitBreakMaterialGroupId"`
|
||||||
|
MaterialId int32 `json:"MaterialId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type costumeLBRef struct {
|
||||||
|
CostumeId int32 `json:"CostumeId"`
|
||||||
|
CostumeLimitBreakMaterialGroupId int32 `json:"CostumeLimitBreakMaterialGroupId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const dupExchangeFallbackCount int32 = 10
|
||||||
|
|
||||||
|
func EnrichDupExchange(dupMap map[int32][]model.DupExchangeEntry, pool *GachaCatalog) (int, error) {
|
||||||
|
lbRows, err := utils.ReadJSON[lbMaterialRow]("EntityMCostumeLimitBreakMaterialGroupTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
groupToMaterial := make(map[int32]int32, len(lbRows))
|
||||||
|
for _, r := range lbRows {
|
||||||
|
groupToMaterial[r.CostumeLimitBreakMaterialGroupId] = r.MaterialId
|
||||||
|
}
|
||||||
|
|
||||||
|
costumeRows, err := utils.ReadJSON[costumeLBRef]("EntityMCostumeTable.json")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
costumeLBGroup := make(map[int32]int32, len(costumeRows))
|
||||||
|
for _, r := range costumeRows {
|
||||||
|
costumeLBGroup[r.CostumeId] = r.CostumeLimitBreakMaterialGroupId
|
||||||
|
}
|
||||||
|
|
||||||
|
added := 0
|
||||||
|
for costumeId := range pool.CostumeById {
|
||||||
|
if _, exists := dupMap[costumeId]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matId := groupToMaterial[costumeLBGroup[costumeId]]
|
||||||
|
if matId == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dupMap[costumeId] = []model.DupExchangeEntry{{
|
||||||
|
PossessionType: int32(model.PossessionTypeMaterial),
|
||||||
|
PossessionId: matId,
|
||||||
|
Count: dupExchangeFallbackCount,
|
||||||
|
}}
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
return added, nil
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
const (
|
||||||
|
ShopGroupTypeUnknown int32 = 0
|
||||||
|
ShopGroupTypePremiumShop int32 = 1
|
||||||
|
ShopGroupTypeItemShop int32 = 3
|
||||||
|
ShopGroupTypeExchangeShop int32 = 4
|
||||||
|
ShopGroupTypeRecoveryShop int32 = 5
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type TutorialType int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
TutorialTypeUnknown TutorialType = 0
|
||||||
|
TutorialTypeGameStart TutorialType = 1
|
||||||
|
TutorialTypeMenuFirst TutorialType = 2
|
||||||
|
TutorialTypeMenuSecond TutorialType = 3
|
||||||
|
TutorialTypeBattleWeaponSkill TutorialType = 4
|
||||||
|
TutorialTypeBattleCostumeSkill TutorialType = 5
|
||||||
|
TutorialTypeBlackBird TutorialType = 6
|
||||||
|
TutorialTypeEnhance TutorialType = 7
|
||||||
|
TutorialTypeCompanion TutorialType = 8
|
||||||
|
TutorialTypeParts TutorialType = 9
|
||||||
|
TutorialTypeExplore TutorialType = 10
|
||||||
|
TutorialTypePvp TutorialType = 11
|
||||||
|
TutorialTypeMainQuestHard TutorialType = 12
|
||||||
|
TutorialTypeMainQuestVeryHard TutorialType = 13
|
||||||
|
TutorialTypeEventQuestFirst TutorialType = 14
|
||||||
|
TutorialTypeEventQuestCharacter TutorialType = 15
|
||||||
|
TutorialTypeEventQuestMarathon TutorialType = 16
|
||||||
|
TutorialTypeEventQuestHunt TutorialType = 17
|
||||||
|
TutorialTypeEventQuestDungeon TutorialType = 18
|
||||||
|
TutorialTypeEventQuestDayOfTheWeek TutorialType = 19
|
||||||
|
TutorialTypeEventQuestGuerrilla TutorialType = 20
|
||||||
|
TutorialTypeEndContents TutorialType = 21
|
||||||
|
TutorialTypeEndContentsQuest TutorialType = 22
|
||||||
|
TutorialTypeExploreGame1 TutorialType = 23
|
||||||
|
TutorialTypeExploreGame2 TutorialType = 24
|
||||||
|
TutorialTypePortalCage TutorialType = 25
|
||||||
|
TutorialTypePortalCageMainQuest TutorialType = 26
|
||||||
|
TutorialTypeCage TutorialType = 27
|
||||||
|
TutorialTypePortalCageDailyQuest TutorialType = 28
|
||||||
|
TutorialTypePortalCageDailyGacha TutorialType = 29
|
||||||
|
TutorialTypePortalCageDropItem TutorialType = 30
|
||||||
|
TutorialTypePortalCageReachedLastScene TutorialType = 31
|
||||||
|
TutorialTypePortalCageCharacter1 TutorialType = 32
|
||||||
|
TutorialTypePortalCageCharacter2 TutorialType = 33
|
||||||
|
TutorialTypePortalCageCharacter3 TutorialType = 34
|
||||||
|
TutorialTypePortalCageCharacter4 TutorialType = 35
|
||||||
|
TutorialTypePortalCageCharacter5 TutorialType = 36
|
||||||
|
TutorialTypeBlackBirdCharacter1 TutorialType = 37
|
||||||
|
TutorialTypeBlackBirdCharacter2 TutorialType = 38
|
||||||
|
TutorialTypeBlackBirdCharacter3 TutorialType = 39
|
||||||
|
TutorialTypeGohobi TutorialType = 40
|
||||||
|
TutorialTypeGohobiDrop TutorialType = 41
|
||||||
|
TutorialTypeBattleCancelEnemyCast1 TutorialType = 42
|
||||||
|
TutorialTypeBattleCancelEnemyCast2 TutorialType = 43
|
||||||
|
TutorialTypeLoseFirst TutorialType = 44
|
||||||
|
TutorialTypeRewardGacha TutorialType = 45
|
||||||
|
TutorialTypeBigWinBonusFirst TutorialType = 46
|
||||||
|
TutorialTypeBigHunt TutorialType = 47
|
||||||
|
TutorialTypeTripleDeck TutorialType = 48
|
||||||
|
TutorialTypeCharacterBoard TutorialType = 49
|
||||||
|
TutorialTypeCharacterBoardBasic TutorialType = 50
|
||||||
|
TutorialTypeCharacterBoardBigHunt TutorialType = 51
|
||||||
|
TutorialTypeWorldMap TutorialType = 52
|
||||||
|
TutorialTypeMapItemFull TutorialType = 53
|
||||||
|
TutorialTypeWorldMapBlackBird TutorialType = 54
|
||||||
|
TutorialTypeWorldMapTreasure TutorialType = 55
|
||||||
|
TutorialTypeBrokenObelisk TutorialType = 56
|
||||||
|
TutorialTypeLoseFirstAfterChapter TutorialType = 57
|
||||||
|
TutorialTypeReplayFlowSkip TutorialType = 58
|
||||||
|
TutorialTypeWorldMapOutgame TutorialType = 59
|
||||||
|
TutorialTypeBattleCertainKillSkill TutorialType = 60
|
||||||
|
TutorialTypeSmartPhoneFirst TutorialType = 101
|
||||||
|
TutorialTypePhotoFirst TutorialType = 102
|
||||||
|
TutorialTypeDailyGacha TutorialType = 103
|
||||||
|
TutorialTypePortalCageSeason TutorialType = 104
|
||||||
|
TutorialTypeQuestSkip TutorialType = 201
|
||||||
|
TutorialTypePortalCageChapter TutorialType = 202
|
||||||
|
TutorialTypeCharacterBoardUnlock TutorialType = 301
|
||||||
|
TutorialTypeBlackBirdSistersFirst TutorialType = 401
|
||||||
|
TutorialTypeCostumeLevelBonus TutorialType = 501
|
||||||
|
TutorialTypeWorldMapReport TutorialType = 601
|
||||||
|
TutorialTypeBossSpecialEffect TutorialType = 701
|
||||||
|
TutorialTypeEventQuestGuerrillaFree TutorialType = 801
|
||||||
|
TutorialTypeExploreHard TutorialType = 901
|
||||||
|
TutorialTypeCageMemory TutorialType = 1001
|
||||||
|
TutorialTypeDressupCostume TutorialType = 1101
|
||||||
|
TutorialTypeCostumeAwaken TutorialType = 1201
|
||||||
|
TutorialTypeThoughtOrganization TutorialType = 1202
|
||||||
|
TutorialTypeHideObelisk TutorialType = 1301
|
||||||
|
TutorialTypeLimitContent TutorialType = 1302
|
||||||
|
TutorialTypeFieldEffect TutorialType = 1303
|
||||||
|
TutorialTypeLimitContentCage TutorialType = 1304
|
||||||
|
TutorialTypeCharacterViewer TutorialType = 1305
|
||||||
|
TutorialTypeRecycleGacha TutorialType = 1306
|
||||||
|
TutorialTypeMomPoint TutorialType = 1401
|
||||||
|
TutorialTypeStainedGlass TutorialType = 1402
|
||||||
|
TutorialTypeCharacterRebirth TutorialType = 1501
|
||||||
|
TutorialTypeWeaponAwaken TutorialType = 1502
|
||||||
|
TutorialTypeEventQuestLabyrinth TutorialType = 1503
|
||||||
|
TutorialTypeProperAttribute TutorialType = 1601
|
||||||
|
TutorialTypeMissionPass TutorialType = 1701
|
||||||
|
TutorialTypeWeaponAllOrganization TutorialType = 1702
|
||||||
|
TutorialTypeCostumeLotteryEffect TutorialType = 1801
|
||||||
|
TutorialTypeAnotherRoute TutorialType = 2001
|
||||||
|
TutorialTypeDeleteCostumeFio TutorialType = 2101
|
||||||
|
)
|
||||||
|
|
||||||
|
type TutorialUnlockConditionType int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
TutorialUnlockConditionTypeFunctionReleased TutorialUnlockConditionType = 1
|
||||||
|
TutorialUnlockConditionTypeReachSpecifiedQuestScene TutorialUnlockConditionType = 2
|
||||||
|
TutorialUnlockConditionTypeUntilReachSpecifiedScene TutorialUnlockConditionType = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// TutorialPhase values are runtime-initialized in the client (static readonly),
|
||||||
|
// so only observed values are listed here.
|
||||||
|
type TutorialPhase int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
TutorialPhaseMomMenuEditDeck TutorialPhase = 20
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package questflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *QuestHandler) HandleBigHuntQuestStart(user *store.UserState, questId, userDeckNumber int32, nowMillis int64) {
|
||||||
|
quest, ok := h.QuestById[questId]
|
||||||
|
if !ok {
|
||||||
|
panic(fmt.Sprintf("unknown questId=%d for HandleBigHuntQuestStart", questId))
|
||||||
|
}
|
||||||
|
|
||||||
|
h.initQuestState(user, questId)
|
||||||
|
|
||||||
|
if quest.Stamina > 0 {
|
||||||
|
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
|
||||||
|
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
questState := user.Quests[questId]
|
||||||
|
questState.UserDeckNumber = userDeckNumber
|
||||||
|
questState.QuestStateType = model.UserQuestStateTypeActive
|
||||||
|
questState.LatestStartDatetime = nowMillis
|
||||||
|
user.Quests[questId] = questState
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuestHandler) HandleBigHuntQuestFinish(user *store.UserState, questId int32, isRetired, isAnnihilated bool, nowMillis int64) FinishOutcome {
|
||||||
|
quest, ok := h.QuestById[questId]
|
||||||
|
if !ok {
|
||||||
|
panic(fmt.Sprintf("unknown questId=%d for HandleBigHuntQuestFinish", questId))
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome := h.evaluateFinishOutcome(user, questId)
|
||||||
|
if !isRetired {
|
||||||
|
h.applyQuestVictory(user, questId, outcome, nowMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isRetired && !isAnnihilated && quest.Stamina > 1 {
|
||||||
|
refund := quest.Stamina - 1
|
||||||
|
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
|
||||||
|
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.clearQuestMissions(user, questId, nowMillis)
|
||||||
|
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type revisionTracker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
activeByClient map[string]string
|
||||||
|
lastRevision string
|
||||||
|
}
|
||||||
|
|
||||||
|
type assetResolution struct {
|
||||||
|
ActiveRevision string
|
||||||
|
ListRevision string
|
||||||
|
ListSize int64
|
||||||
|
Candidates []assetCandidate
|
||||||
|
}
|
||||||
|
|
||||||
|
type assetResolver struct{}
|
||||||
|
|
||||||
|
func newRevisionTracker() *revisionTracker {
|
||||||
|
return &revisionTracker{
|
||||||
|
activeByClient: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAssetResolver() *assetResolver {
|
||||||
|
return &assetResolver{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeClientAddr(remoteAddr string) string {
|
||||||
|
host, _, err := net.SplitHostPort(remoteAddr)
|
||||||
|
if err == nil && host != "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return remoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *revisionTracker) Remember(clientAddr, revision string) {
|
||||||
|
if revision == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client := normalizeClientAddr(clientAddr)
|
||||||
|
t.mu.Lock()
|
||||||
|
if client != "" {
|
||||||
|
t.activeByClient[client] = revision
|
||||||
|
}
|
||||||
|
t.lastRevision = revision
|
||||||
|
t.mu.Unlock()
|
||||||
|
log.Printf("[Octo] Active list revision for client=%s set to %s", client, revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *revisionTracker) Active(clientAddr string) string {
|
||||||
|
client := normalizeClientAddr(clientAddr)
|
||||||
|
t.mu.RLock()
|
||||||
|
revision := t.activeByClient[client]
|
||||||
|
if revision == "" {
|
||||||
|
revision = t.lastRevision
|
||||||
|
}
|
||||||
|
t.mu.RUnlock()
|
||||||
|
if revision == "" {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
return revision
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *assetResolver) Resolve(objectId, assetType, activeRevision string) (assetResolution, bool) {
|
||||||
|
start := time.Now()
|
||||||
|
resolution := assetResolution{ActiveRevision: activeRevision}
|
||||||
|
revision := activeRevision
|
||||||
|
|
||||||
|
candidates, listSize, ok := objectIdToFilePathCandidates(revision, assetType, objectId)
|
||||||
|
if ok && len(candidates) > 0 {
|
||||||
|
resolution.ListRevision = revision
|
||||||
|
resolution.ListSize = listSize
|
||||||
|
resolution.Candidates = candidates
|
||||||
|
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||||
|
log.Printf("[HTTP] Asset resolve slow: object_id=%s type=%s active_revision=%s list_revision=%s elapsed=%s", objectId, assetType, activeRevision, revision, elapsed)
|
||||||
|
}
|
||||||
|
return resolution, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||||
|
log.Printf("[HTTP] Asset resolve miss: object_id=%s type=%s active_revision=%s elapsed=%s", objectId, assetType, activeRevision, elapsed)
|
||||||
|
}
|
||||||
|
return resolution, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *assetResolver) Prewarm(activeRevision string) {
|
||||||
|
if activeRevision == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = loadListBinIndex(activeRevision)
|
||||||
|
_ = loadInfoIndex(activeRevision)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BannerServiceServer struct {
|
||||||
|
pb.UnimplementedBannerServiceServer
|
||||||
|
gacha store.GachaRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBannerServiceServer(gacha store.GachaRepository) *BannerServiceServer {
|
||||||
|
return &BannerServiceServer{gacha: gacha}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BannerServiceServer) GetMamaBanner(ctx context.Context, req *pb.GetMamaBannerRequest) (*pb.GetMamaBannerResponse, error) {
|
||||||
|
catalog, _ := s.gacha.SnapshotCatalog()
|
||||||
|
var termLimited []*pb.GachaBanner
|
||||||
|
var latestChapter *pb.GachaBanner
|
||||||
|
for _, entry := range catalog {
|
||||||
|
if entry.GachaLabelType == model.GachaLabelPortalCage || entry.GachaLabelType == model.GachaLabelRecycle {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b := &pb.GachaBanner{
|
||||||
|
GachaLabelType: entry.GachaLabelType,
|
||||||
|
GachaAssetName: entry.BannerAssetName,
|
||||||
|
GachaId: entry.GachaId,
|
||||||
|
}
|
||||||
|
switch entry.GachaLabelType {
|
||||||
|
case model.GachaLabelEvent, model.GachaLabelPremium:
|
||||||
|
termLimited = append(termLimited, b)
|
||||||
|
case model.GachaLabelChapter:
|
||||||
|
if latestChapter == nil || entry.GachaId > latestChapter.GachaId {
|
||||||
|
latestChapter = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &pb.GetMamaBannerResponse{
|
||||||
|
TermLimitedGacha: termLimited,
|
||||||
|
LatestChapterGacha: latestChapter,
|
||||||
|
IsExistUnreadPop: false,
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BattleServiceServer struct {
|
||||||
|
pb.UnimplementedBattleServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBattleServiceServer(users store.UserRepository, sessions store.SessionRepository) *BattleServiceServer {
|
||||||
|
return &BattleServiceServer{users: users, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BattleServiceServer) StartWave(ctx context.Context, req *pb.StartWaveRequest) (*pb.StartWaveResponse, error) {
|
||||||
|
log.Printf("[BattleService] StartWave: userParty=%d npcParty=%d", len(req.UserPartyInitialInfoList), len(req.NpcPartyInitialInfoList))
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.Battle.IsActive = true
|
||||||
|
user.Battle.StartCount++
|
||||||
|
user.Battle.LastStartedAt = gametime.NowMillis()
|
||||||
|
user.Battle.LastUserPartyCount = int32(len(req.UserPartyInitialInfoList))
|
||||||
|
user.Battle.LastNpcPartyCount = int32(len(req.NpcPartyInitialInfoList))
|
||||||
|
})
|
||||||
|
return &pb.StartWaveResponse{
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BattleServiceServer) FinishWave(ctx context.Context, req *pb.FinishWaveRequest) (*pb.FinishWaveResponse, error) {
|
||||||
|
log.Printf("[BattleService] FinishWave: battleBinary=%d userParty=%d npcParty=%d elapsedFrames=%d",
|
||||||
|
len(req.BattleBinary), len(req.UserPartyResultInfoList), len(req.NpcPartyResultInfoList), req.ElapsedFrameCount)
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.Battle.IsActive = false
|
||||||
|
user.Battle.FinishCount++
|
||||||
|
user.Battle.LastFinishedAt = gametime.NowMillis()
|
||||||
|
user.Battle.LastUserPartyCount = int32(len(req.UserPartyResultInfoList))
|
||||||
|
user.Battle.LastNpcPartyCount = int32(len(req.NpcPartyResultInfoList))
|
||||||
|
user.Battle.LastBattleBinarySize = int32(len(req.BattleBinary))
|
||||||
|
user.Battle.LastElapsedFrameCount = req.ElapsedFrameCount
|
||||||
|
})
|
||||||
|
return &pb.FinishWaveResponse{
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CageOrnamentServiceServer struct {
|
||||||
|
pb.UnimplementedCageOrnamentServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.CageOrnamentCatalog
|
||||||
|
granter *store.PossessionGranter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCageOrnamentServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CageOrnamentCatalog, granter *store.PossessionGranter) *CageOrnamentServiceServer {
|
||||||
|
return &CageOrnamentServiceServer{users: users, sessions: sessions, catalog: catalog, granter: granter}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.ReceiveRewardRequest) (*pb.ReceiveRewardResponse, error) {
|
||||||
|
log.Printf("[CageOrnamentService] ReceiveReward: cageOrnamentId=%d", req.CageOrnamentId)
|
||||||
|
|
||||||
|
reward, ok := s.catalog.LookupReward(req.CageOrnamentId)
|
||||||
|
if !ok {
|
||||||
|
log.Fatalf("[CageOrnamentService] ReceiveReward: no reward for cageOrnamentId=%d", req.CageOrnamentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.CageOrnamentRewards[req.CageOrnamentId] = store.CageOrnamentRewardState{
|
||||||
|
CageOrnamentId: req.CageOrnamentId,
|
||||||
|
AcquisitionDatetime: nowMillis,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
s.granter.GrantFull(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis)
|
||||||
|
})
|
||||||
|
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(
|
||||||
|
userdata.FullClientTableMap(user),
|
||||||
|
[]string{
|
||||||
|
"IUserMaterial", "IUserConsumableItem", "IUserGem",
|
||||||
|
"IUserCostume", "IUserCostumeActiveSkill", "IUserCharacter",
|
||||||
|
"IUserWeapon", "IUserWeaponSkill", "IUserWeaponAbility",
|
||||||
|
"IUserWeaponNote", "IUserWeaponStory",
|
||||||
|
"IUserCageOrnamentReward",
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
return &pb.ReceiveRewardResponse{
|
||||||
|
CageOrnamentReward: []*pb.CageOrnamentReward{
|
||||||
|
{
|
||||||
|
PossessionType: reward.PossessionType,
|
||||||
|
PossessionId: reward.PossessionId,
|
||||||
|
Count: reward.Count,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CageOrnamentServiceServer) RecordAccess(ctx context.Context, req *pb.RecordAccessRequest) (*pb.RecordAccessResponse, error) {
|
||||||
|
log.Printf("[CageOrnamentService] RecordAccess: cageOrnamentId=%d", req.CageOrnamentId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
if _, exists := user.CageOrnamentRewards[req.CageOrnamentId]; !exists {
|
||||||
|
user.CageOrnamentRewards[req.CageOrnamentId] = store.CageOrnamentRewardState{
|
||||||
|
CageOrnamentId: req.CageOrnamentId,
|
||||||
|
AcquisitionDatetime: nowMillis,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(
|
||||||
|
userdata.FullClientTableMap(user),
|
||||||
|
[]string{"IUserCageOrnamentReward"},
|
||||||
|
))
|
||||||
|
|
||||||
|
return &pb.RecordAccessResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CharacterServiceServer struct {
|
||||||
|
pb.UnimplementedCharacterServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.CharacterRebirthCatalog
|
||||||
|
config *masterdata.GameConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCharacterServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterRebirthCatalog, config *masterdata.GameConfig) *CharacterServiceServer {
|
||||||
|
return &CharacterServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CharacterServiceServer) Rebirth(ctx context.Context, req *pb.RebirthRequest) (*pb.RebirthResponse, error) {
|
||||||
|
log.Printf("[CharacterService] Rebirth: characterId=%d rebirthCount=%d", req.CharacterId, req.RebirthCount)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
stepGroupId, ok := s.catalog.StepGroupByCharacterId[req.CharacterId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CharacterService] Rebirth: no step group for characterId=%d", req.CharacterId)
|
||||||
|
return &pb.RebirthResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
oldUser, _ := s.users.SnapshotUser(userId)
|
||||||
|
tracker := userdata.NewDeleteTracker().
|
||||||
|
Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"})
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
current := user.CharacterRebirths[req.CharacterId]
|
||||||
|
currentCount := current.RebirthCount
|
||||||
|
targetCount := currentCount + req.RebirthCount
|
||||||
|
|
||||||
|
for count := currentCount; count < targetCount; count++ {
|
||||||
|
step, ok := s.catalog.StepByGroupAndCount[masterdata.StepKey{GroupId: stepGroupId, BeforeRebirthCount: count}]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CharacterService] Rebirth: no step row for groupId=%d beforeCount=%d", stepGroupId, count)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
goldId := s.config.ConsumableItemIdForGold
|
||||||
|
user.ConsumableItems[goldId] = max(user.ConsumableItems[goldId]-s.config.CharacterRebirthConsumeGold, 0)
|
||||||
|
log.Printf("[CharacterService] Rebirth: consumed gold=%d", s.config.CharacterRebirthConsumeGold)
|
||||||
|
|
||||||
|
materials := s.catalog.MaterialsByGroupId[step.CharacterRebirthMaterialGroupId]
|
||||||
|
for _, mat := range materials {
|
||||||
|
user.Materials[mat.MaterialId] -= mat.Count
|
||||||
|
if user.Materials[mat.MaterialId] <= 0 {
|
||||||
|
delete(user.Materials, mat.MaterialId)
|
||||||
|
}
|
||||||
|
log.Printf("[CharacterService] Rebirth: consumed material=%d count=%d", mat.MaterialId, mat.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[CharacterService] Rebirth: characterId=%d count %d -> %d", req.CharacterId, currentCount, targetCount)
|
||||||
|
user.CharacterRebirths[req.CharacterId] = store.CharacterRebirthState{
|
||||||
|
CharacterId: req.CharacterId,
|
||||||
|
RebirthCount: targetCount,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[CharacterService] Rebirth error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rebirthTables := []string{"IUserCharacterRebirth", "IUserMaterial", "IUserConsumableItem"}
|
||||||
|
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), rebirthTables)
|
||||||
|
diff := tracker.Apply(snapshot, tables)
|
||||||
|
|
||||||
|
return &pb.RebirthResponse{DiffUserData: diff}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CharacterBoardServiceServer struct {
|
||||||
|
pb.UnimplementedCharacterBoardServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.CharacterBoardCatalog
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCharacterBoardServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterBoardCatalog) *CharacterBoardServiceServer {
|
||||||
|
return &CharacterBoardServiceServer{users: users, sessions: sessions, catalog: catalog}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CharacterBoardServiceServer) ReleasePanel(ctx context.Context, req *pb.ReleasePanelRequest) (*pb.ReleasePanelResponse, error) {
|
||||||
|
log.Printf("[CharacterBoardService] ReleasePanel: panelIds=%v", req.CharacterBoardPanelId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
|
||||||
|
oldUser, _ := s.users.SnapshotUser(userId)
|
||||||
|
tracker := userdata.NewDeleteTracker().
|
||||||
|
Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"}).
|
||||||
|
Track("IUserConsumableItem", oldUser, userdata.SortedConsumableItemRecords, []string{"userId", "consumableItemId"})
|
||||||
|
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
for _, panelId := range req.CharacterBoardPanelId {
|
||||||
|
panel, ok := s.catalog.PanelById[panelId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CharacterBoardService] unknown panelId=%d, skipping", panelId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.consumeCosts(user, panel)
|
||||||
|
s.setReleaseBit(user, panel)
|
||||||
|
s.applyEffects(user, panel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
boardTables := []string{
|
||||||
|
"IUserCharacterBoard",
|
||||||
|
"IUserCharacterBoardAbility",
|
||||||
|
"IUserCharacterBoardStatusUp",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserGem",
|
||||||
|
}
|
||||||
|
tables := userdata.SelectTables(userdata.FullClientTableMap(user), boardTables)
|
||||||
|
diff := tracker.Apply(user, tables)
|
||||||
|
|
||||||
|
return &pb.ReleasePanelResponse{DiffUserData: diff}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CharacterBoardServiceServer) consumeCosts(user *store.UserState, panel masterdata.CharacterBoardPanelRow) {
|
||||||
|
costs := s.catalog.ReleaseCostsByGroupId[panel.CharacterBoardPanelReleasePossessionGroupId]
|
||||||
|
for _, cost := range costs {
|
||||||
|
store.DeductPossession(user, model.PossessionType(cost.PossessionType), cost.PossessionId, cost.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CharacterBoardServiceServer) setReleaseBit(user *store.UserState, panel masterdata.CharacterBoardPanelRow) {
|
||||||
|
boardId := panel.CharacterBoardId
|
||||||
|
board := user.CharacterBoards[boardId]
|
||||||
|
board.CharacterBoardId = boardId
|
||||||
|
|
||||||
|
bitFieldIndex := (panel.SortOrder - 1) / 32
|
||||||
|
bitPosition := (panel.SortOrder - 1) % 32
|
||||||
|
mask := int32(1 << uint(bitPosition))
|
||||||
|
|
||||||
|
switch bitFieldIndex {
|
||||||
|
case 0:
|
||||||
|
board.PanelReleaseBit1 |= mask
|
||||||
|
case 1:
|
||||||
|
board.PanelReleaseBit2 |= mask
|
||||||
|
case 2:
|
||||||
|
board.PanelReleaseBit3 |= mask
|
||||||
|
case 3:
|
||||||
|
board.PanelReleaseBit4 |= mask
|
||||||
|
}
|
||||||
|
|
||||||
|
user.CharacterBoards[boardId] = board
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CharacterBoardServiceServer) applyEffects(user *store.UserState, panel masterdata.CharacterBoardPanelRow) {
|
||||||
|
effects := s.catalog.ReleaseEffectsByGroupId[panel.CharacterBoardPanelReleaseEffectGroupId]
|
||||||
|
for _, eff := range effects {
|
||||||
|
switch model.CharacterBoardEffectType(eff.CharacterBoardEffectType) {
|
||||||
|
case model.CharacterBoardEffectTypeAbility:
|
||||||
|
s.applyAbilityEffect(user, eff)
|
||||||
|
case model.CharacterBoardEffectTypeStatusUp:
|
||||||
|
s.applyStatusUpEffect(user, eff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CharacterBoardServiceServer) applyAbilityEffect(user *store.UserState, eff masterdata.CharacterBoardReleaseEffectRow) {
|
||||||
|
ability, ok := s.catalog.AbilityById[eff.CharacterBoardEffectId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CharacterBoardService] unknown abilityId=%d", eff.CharacterBoardEffectId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
characterId := s.resolveCharacterId(ability.CharacterBoardEffectTargetGroupId)
|
||||||
|
if characterId == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := store.CharacterBoardAbilityKey{CharacterId: characterId, AbilityId: ability.AbilityId}
|
||||||
|
state := user.CharacterBoardAbilities[key]
|
||||||
|
state.CharacterId = characterId
|
||||||
|
state.AbilityId = ability.AbilityId
|
||||||
|
state.Level += eff.EffectValue
|
||||||
|
|
||||||
|
if maxLvl, ok := s.catalog.AbilityMaxLevel[key]; ok && state.Level > maxLvl {
|
||||||
|
state.Level = maxLvl
|
||||||
|
}
|
||||||
|
|
||||||
|
user.CharacterBoardAbilities[key] = state
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CharacterBoardServiceServer) applyStatusUpEffect(user *store.UserState, eff masterdata.CharacterBoardReleaseEffectRow) {
|
||||||
|
statusUp, ok := s.catalog.StatusUpById[eff.CharacterBoardEffectId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CharacterBoardService] unknown statusUpId=%d", eff.CharacterBoardEffectId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
characterId := s.resolveCharacterId(statusUp.CharacterBoardEffectTargetGroupId)
|
||||||
|
if characterId == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
supType := model.CharacterBoardStatusUpType(statusUp.CharacterBoardStatusUpType)
|
||||||
|
calcType := model.StatusUpTypeToCalcType(supType)
|
||||||
|
|
||||||
|
key := store.CharacterBoardStatusUpKey{
|
||||||
|
CharacterId: characterId,
|
||||||
|
StatusCalculationType: int32(calcType),
|
||||||
|
}
|
||||||
|
state := user.CharacterBoardStatusUps[key]
|
||||||
|
state.CharacterId = characterId
|
||||||
|
state.StatusCalculationType = int32(calcType)
|
||||||
|
|
||||||
|
switch supType {
|
||||||
|
case model.CharacterBoardStatusUpTypeAgilityAdd, model.CharacterBoardStatusUpTypeAgilityMultiply:
|
||||||
|
state.Agility += eff.EffectValue
|
||||||
|
case model.CharacterBoardStatusUpTypeAttackAdd, model.CharacterBoardStatusUpTypeAttackMultiply:
|
||||||
|
state.Attack += eff.EffectValue
|
||||||
|
case model.CharacterBoardStatusUpTypeCritAttackAdd:
|
||||||
|
state.CriticalAttack += eff.EffectValue
|
||||||
|
case model.CharacterBoardStatusUpTypeCritRatioAdd:
|
||||||
|
state.CriticalRatio += eff.EffectValue
|
||||||
|
case model.CharacterBoardStatusUpTypeHpAdd, model.CharacterBoardStatusUpTypeHpMultiply:
|
||||||
|
state.Hp += eff.EffectValue
|
||||||
|
case model.CharacterBoardStatusUpTypeVitalityAdd, model.CharacterBoardStatusUpTypeVitalityMultiply:
|
||||||
|
state.Vitality += eff.EffectValue
|
||||||
|
}
|
||||||
|
|
||||||
|
user.CharacterBoardStatusUps[key] = state
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CharacterBoardServiceServer) resolveCharacterId(targetGroupId int32) int32 {
|
||||||
|
targets := s.catalog.EffectTargetsByGroupId[targetGroupId]
|
||||||
|
for _, t := range targets {
|
||||||
|
if t.TargetValue != 0 {
|
||||||
|
return t.TargetValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[CharacterBoardService] no characterId resolved for targetGroupId=%d", targetGroupId)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CharacterViewerServiceServer struct {
|
||||||
|
pb.UnimplementedCharacterViewerServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.CharacterViewerCatalog
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCharacterViewerServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterViewerCatalog) *CharacterViewerServiceServer {
|
||||||
|
return &CharacterViewerServiceServer{users: users, sessions: sessions, catalog: catalog}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CharacterViewerServiceServer) CharacterViewerTop(ctx context.Context, _ *emptypb.Empty) (*pb.CharacterViewerTopResponse, error) {
|
||||||
|
log.Printf("[CharacterViewerService] CharacterViewerTop")
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, err := s.users.SnapshotUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("CharacterViewerTop: no user for userId=%d: %v", userId, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
released := s.catalog.ReleasedFieldIds(user)
|
||||||
|
log.Printf("[CharacterViewerService] released %d fields for user %d", len(released), userId)
|
||||||
|
|
||||||
|
now := gametime.NowMillis()
|
||||||
|
records := make([]map[string]any, 0, len(released))
|
||||||
|
for _, fieldId := range released {
|
||||||
|
records = append(records, map[string]any{
|
||||||
|
"userId": userId,
|
||||||
|
"characterViewerFieldId": fieldId,
|
||||||
|
"releaseDatetime": now,
|
||||||
|
"latestVersion": 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := "[]"
|
||||||
|
if len(records) > 0 {
|
||||||
|
data, _ := json.Marshal(records)
|
||||||
|
payload = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
diff := map[string]*pb.DiffData{
|
||||||
|
"IUserCharacterViewerField": {
|
||||||
|
UpdateRecordsJson: payload,
|
||||||
|
DeleteKeysJson: "[]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.CharacterViewerTopResponse{
|
||||||
|
ReleaseCharacterViewerFieldId: released,
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const companionMaxLevel = int32(50)
|
||||||
|
|
||||||
|
var companionDiffTables = []string{
|
||||||
|
"IUserCompanion",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompanionServiceServer struct {
|
||||||
|
pb.UnimplementedCompanionServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.CompanionCatalog
|
||||||
|
config *masterdata.GameConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCompanionServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CompanionCatalog, config *masterdata.GameConfig) *CompanionServiceServer {
|
||||||
|
return &CompanionServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CompanionServiceServer) Enhance(ctx context.Context, req *pb.CompanionEnhanceRequest) (*pb.CompanionEnhanceResponse, error) {
|
||||||
|
log.Printf("[CompanionService] Enhance: uuid=%s addLevel=%d", req.UserCompanionUuid, req.AddLevelCount)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
companion, ok := user.Companions[req.UserCompanionUuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CompanionService] Enhance: companion uuid=%s not found", req.UserCompanionUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
compDef, ok := s.catalog.CompanionById[companion.CompanionId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CompanionService] Enhance: companion master id=%d not found", companion.CompanionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetLevel := companion.Level + req.AddLevelCount
|
||||||
|
if targetLevel > companionMaxLevel {
|
||||||
|
targetLevel = companionMaxLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
for lvl := companion.Level; lvl < targetLevel; lvl++ {
|
||||||
|
if costFunc, ok := s.catalog.GoldCostByCategory[compDef.CompanionCategoryType]; ok {
|
||||||
|
goldCost := costFunc.Evaluate(lvl)
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
|
||||||
|
}
|
||||||
|
|
||||||
|
matKey := masterdata.CompanionLevelKey{CategoryType: compDef.CompanionCategoryType, Level: lvl}
|
||||||
|
if mat, ok := s.catalog.MaterialsByKey[matKey]; ok {
|
||||||
|
user.Materials[mat.MaterialId] -= mat.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion.Level = targetLevel
|
||||||
|
companion.LatestVersion = nowMillis
|
||||||
|
user.Companions[req.UserCompanionUuid] = companion
|
||||||
|
log.Printf("[CompanionService] Enhance: companionId=%d level -> %d", companion.CompanionId, targetLevel)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("companion enhance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, companionDiffTables))
|
||||||
|
|
||||||
|
return &pb.CompanionEnhanceResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfigServiceServer struct {
|
||||||
|
pb.UnimplementedConfigServiceServer
|
||||||
|
GrpcHost string
|
||||||
|
GrpcPort int32
|
||||||
|
OctoURL string // HTTP base URL for Octo (list + assets); client uses this instead of default resources.app.nierreincarnation.com
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfigServiceServer(host string, port int32, octoURL string) *ConfigServiceServer {
|
||||||
|
return &ConfigServiceServer{GrpcHost: host, GrpcPort: port, OctoURL: octoURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConfigServiceServer) GetReviewServerConfig(ctx context.Context, _ *emptypb.Empty) (*pb.GetReviewServerConfigResponse, error) {
|
||||||
|
log.Printf("[ConfigService] GetReviewServerConfig -> %s:%d", s.GrpcHost, s.GrpcPort)
|
||||||
|
|
||||||
|
return &pb.GetReviewServerConfigResponse{
|
||||||
|
Api: &pb.ApiConfig{
|
||||||
|
Hostname: s.GrpcHost,
|
||||||
|
Port: s.GrpcPort,
|
||||||
|
},
|
||||||
|
Octo: &pb.OctoConfig{
|
||||||
|
Version: 1,
|
||||||
|
AppId: 1,
|
||||||
|
ClientSecretKey: "secret",
|
||||||
|
AesKey: "aeskey",
|
||||||
|
Url: s.OctoURL,
|
||||||
|
},
|
||||||
|
WebView: &pb.WebViewConfig{
|
||||||
|
BaseUrl: s.OctoURL,
|
||||||
|
},
|
||||||
|
MasterData: &pb.MasterDataConfig{
|
||||||
|
UrlFormat: s.OctoURL + "/master-data/%s",
|
||||||
|
},
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContentsStoryServiceServer struct {
|
||||||
|
pb.UnimplementedContentsStoryServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewContentsStoryServiceServer(users store.UserRepository, sessions store.SessionRepository) *ContentsStoryServiceServer {
|
||||||
|
return &ContentsStoryServiceServer{users: users, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ContentsStoryServiceServer) RegisterPlayed(ctx context.Context, req *pb.ContentsStoryRegisterPlayedRequest) (*pb.ContentsStoryRegisterPlayedResponse, error) {
|
||||||
|
log.Printf("[ContentsStoryService] RegisterPlayed: contentsStoryId=%d", req.ContentsStoryId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.ContentsStories[req.ContentsStoryId] = nowMillis
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserContentsStory"}))
|
||||||
|
|
||||||
|
return &pb.ContentsStoryRegisterPlayedResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/gameutil"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
var costumeDiffTables = []string{
|
||||||
|
"IUserCostume",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
}
|
||||||
|
|
||||||
|
type CostumeServiceServer struct {
|
||||||
|
pb.UnimplementedCostumeServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.CostumeCatalog
|
||||||
|
config *masterdata.GameConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCostumeServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CostumeCatalog, config *masterdata.GameConfig) *CostumeServiceServer {
|
||||||
|
return &CostumeServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceRequest) (*pb.EnhanceResponse, error) {
|
||||||
|
log.Printf("[CostumeService] Enhance: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
costume, ok := user.Costumes[req.UserCostumeUuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CostumeService] Enhance: costume uuid=%s not found", req.UserCostumeUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cm, ok := s.catalog.Costumes[costume.CostumeId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CostumeService] Enhance: costume master id=%d not found", costume.CostumeId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalExp := int32(0)
|
||||||
|
totalMaterialCount := int32(0)
|
||||||
|
for materialId, count := range req.Materials {
|
||||||
|
mat, ok := s.catalog.Materials[materialId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CostumeService] Enhance: material id=%d not found, skipping", materialId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cur := user.Materials[materialId]
|
||||||
|
if cur < count {
|
||||||
|
log.Printf("[CostumeService] Enhance: insufficient material id=%d have=%d need=%d", materialId, cur, count)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
user.Materials[materialId] = cur - count
|
||||||
|
totalMaterialCount += count
|
||||||
|
|
||||||
|
expPerUnit := mat.EffectValue
|
||||||
|
if mat.WeaponType != 0 && mat.WeaponType == cm.SkillfulWeaponType {
|
||||||
|
expPerUnit = expPerUnit * s.config.MaterialSameWeaponExpCoefficientPermil / 1000
|
||||||
|
}
|
||||||
|
totalExp += expPerUnit * count
|
||||||
|
}
|
||||||
|
|
||||||
|
if costFunc, ok := s.catalog.EnhanceCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
|
||||||
|
goldCost := costFunc.Evaluate(totalMaterialCount)
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
|
||||||
|
log.Printf("[CostumeService] Enhance: gold cost=%d (materials=%d)", goldCost, totalMaterialCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
costume.Exp += totalExp
|
||||||
|
|
||||||
|
if thresholds, ok := s.catalog.ExpByRarity[cm.RarityType]; ok {
|
||||||
|
costume.Level, costume.Exp = gameutil.LevelAndCap(costume.Exp, thresholds)
|
||||||
|
}
|
||||||
|
|
||||||
|
costume.LatestVersion = nowMillis
|
||||||
|
user.Costumes[req.UserCostumeUuid] = costume
|
||||||
|
log.Printf("[CostumeService] Enhance: costumeId=%d +%d exp -> total=%d level=%d", costume.CostumeId, totalExp, costume.Exp, costume.Level)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("costume enhance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, costumeDiffTables))
|
||||||
|
|
||||||
|
return &pb.EnhanceResponse{
|
||||||
|
IsGreatSuccess: false,
|
||||||
|
SurplusEnhanceMaterial: map[int32]int32{},
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var awakenDiffTables = []string{
|
||||||
|
"IUserCostume",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserCostumeAwakenStatusUp",
|
||||||
|
"IUserThought",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest) (*pb.AwakenResponse, error) {
|
||||||
|
log.Printf("[CostumeService] Awaken: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
costume, ok := user.Costumes[req.UserCostumeUuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CostumeService] Awaken: costume uuid=%s not found", req.UserCostumeUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
awakenRow, ok := s.catalog.AwakenByCostumeId[costume.CostumeId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CostumeService] Awaken: no awaken data for costumeId=%d", costume.CostumeId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nextStep := costume.AwakenCount + 1
|
||||||
|
|
||||||
|
if gold, ok := s.catalog.AwakenPriceByGroup[awakenRow.CostumeAwakenPriceGroupId]; ok {
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= gold
|
||||||
|
log.Printf("[CostumeService] Awaken: gold cost=%d", gold)
|
||||||
|
}
|
||||||
|
|
||||||
|
for materialId, count := range req.Materials {
|
||||||
|
cur := user.Materials[materialId]
|
||||||
|
if cur < count {
|
||||||
|
log.Printf("[CostumeService] Awaken: insufficient material id=%d have=%d need=%d", materialId, cur, count)
|
||||||
|
count = cur
|
||||||
|
}
|
||||||
|
user.Materials[materialId] = cur - count
|
||||||
|
}
|
||||||
|
|
||||||
|
costume.AwakenCount = nextStep
|
||||||
|
costume.LatestVersion = nowMillis
|
||||||
|
user.Costumes[req.UserCostumeUuid] = costume
|
||||||
|
log.Printf("[CostumeService] Awaken: costumeId=%d awakenCount=%d", costume.CostumeId, nextStep)
|
||||||
|
|
||||||
|
effectSteps, ok := s.catalog.AwakenEffectsByGroupAndStep[awakenRow.CostumeAwakenEffectGroupId]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
effect, ok := effectSteps[nextStep]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch model.CostumeAwakenEffectType(effect.CostumeAwakenEffectType) {
|
||||||
|
case model.CostumeAwakenEffectTypeStatusUp:
|
||||||
|
s.applyAwakenStatusUp(user, req.UserCostumeUuid, effect.CostumeAwakenEffectId, nowMillis)
|
||||||
|
case model.CostumeAwakenEffectTypeAbility:
|
||||||
|
log.Printf("[CostumeService] Awaken: ability effect id=%d (client-resolved)", effect.CostumeAwakenEffectId)
|
||||||
|
case model.CostumeAwakenEffectTypeItemAcquire:
|
||||||
|
s.applyAwakenItemAcquire(user, effect.CostumeAwakenEffectId, nowMillis)
|
||||||
|
default:
|
||||||
|
log.Printf("[CostumeService] Awaken: unknown effect type=%d", effect.CostumeAwakenEffectType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("costume awaken: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, awakenDiffTables))
|
||||||
|
|
||||||
|
return &pb.AwakenResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CostumeServiceServer) applyAwakenStatusUp(user *store.UserState, costumeUuid string, statusUpGroupId int32, nowMillis int64) {
|
||||||
|
rows, ok := s.catalog.AwakenStatusUpByGroup[statusUpGroupId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CostumeService] Awaken: status up group %d not found", statusUpGroupId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
calcType := model.StatusCalculationType(row.StatusCalculationType)
|
||||||
|
key := store.CostumeAwakenStatusKey{
|
||||||
|
UserCostumeUuid: costumeUuid,
|
||||||
|
StatusCalculationType: calcType,
|
||||||
|
}
|
||||||
|
state := user.CostumeAwakenStatusUps[key]
|
||||||
|
state.UserCostumeUuid = costumeUuid
|
||||||
|
state.StatusCalculationType = calcType
|
||||||
|
|
||||||
|
switch model.StatusKindType(row.StatusKindType) {
|
||||||
|
case model.StatusKindTypeHp:
|
||||||
|
state.Hp += row.EffectValue
|
||||||
|
case model.StatusKindTypeAttack:
|
||||||
|
state.Attack += row.EffectValue
|
||||||
|
case model.StatusKindTypeVitality:
|
||||||
|
state.Vitality += row.EffectValue
|
||||||
|
case model.StatusKindTypeAgility:
|
||||||
|
state.Agility += row.EffectValue
|
||||||
|
case model.StatusKindTypeCriticalRatio:
|
||||||
|
state.CriticalRatio += row.EffectValue
|
||||||
|
case model.StatusKindTypeCriticalAttack:
|
||||||
|
state.CriticalAttack += row.EffectValue
|
||||||
|
}
|
||||||
|
|
||||||
|
state.LatestVersion = nowMillis
|
||||||
|
user.CostumeAwakenStatusUps[key] = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CostumeServiceServer) applyAwakenItemAcquire(user *store.UserState, itemAcquireId int32, nowMillis int64) {
|
||||||
|
acq, ok := s.catalog.AwakenItemAcquireById[itemAcquireId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CostumeService] Awaken: item acquire id=%d not found", itemAcquireId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fmt.Sprintf("awaken-thought-%d", acq.PossessionId)
|
||||||
|
if _, exists := user.Thoughts[key]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user.Thoughts[key] = store.ThoughtState{
|
||||||
|
UserThoughtUuid: key,
|
||||||
|
ThoughtId: acq.PossessionId,
|
||||||
|
AcquisitionDatetime: nowMillis,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
log.Printf("[CostumeService] Awaken: granted thought id=%d", acq.PossessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeSkillDiffTables = []string{
|
||||||
|
"IUserCostumeActiveSkill",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.EnhanceActiveSkillRequest) (*pb.EnhanceActiveSkillResponse, error) {
|
||||||
|
log.Printf("[CostumeService] EnhanceActiveSkill: uuid=%s addLevel=%d", req.UserCostumeUuid, req.AddLevelCount)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
costume, ok := user.Costumes[req.UserCostumeUuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CostumeService] EnhanceActiveSkill: costume uuid=%s not found", req.UserCostumeUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cm, ok := s.catalog.Costumes[costume.CostumeId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CostumeService] EnhanceActiveSkill: costume master id=%d not found", costume.CostumeId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupRows := s.catalog.ActiveSkillGroupsByGroupId[cm.CostumeActiveSkillGroupId]
|
||||||
|
enhanceMatId := int32(-1)
|
||||||
|
for _, g := range groupRows {
|
||||||
|
if g.CostumeLimitBreakCountLowerLimit <= costume.LimitBreakCount {
|
||||||
|
enhanceMatId = g.CostumeActiveSkillEnhancementMaterialId
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if enhanceMatId < 0 {
|
||||||
|
log.Printf("[CostumeService] EnhanceActiveSkill: no skill group for costumeId=%d groupId=%d lb=%d",
|
||||||
|
costume.CostumeId, cm.CostumeActiveSkillGroupId, costume.LimitBreakCount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
skill := user.CostumeActiveSkills[req.UserCostumeUuid]
|
||||||
|
currentLevel := skill.Level
|
||||||
|
|
||||||
|
maxLevelFunc, ok := s.catalog.ActiveSkillMaxLevelByRarity[cm.RarityType]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CostumeService] EnhanceActiveSkill: no max level func for rarity=%d", cm.RarityType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
maxLevel := maxLevelFunc.Evaluate(0)
|
||||||
|
|
||||||
|
addCount := req.AddLevelCount
|
||||||
|
if currentLevel+addCount > maxLevel {
|
||||||
|
addCount = maxLevel - currentLevel
|
||||||
|
}
|
||||||
|
if addCount <= 0 {
|
||||||
|
log.Printf("[CostumeService] EnhanceActiveSkill: already at max level %d", currentLevel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
|
||||||
|
key := [2]int32{enhanceMatId, lvl}
|
||||||
|
mats := s.catalog.ActiveSkillEnhanceMats[key]
|
||||||
|
for _, mat := range mats {
|
||||||
|
cur := user.Materials[mat.MaterialId]
|
||||||
|
cost := mat.Count
|
||||||
|
if cur < cost {
|
||||||
|
log.Printf("[CostumeService] EnhanceActiveSkill: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost)
|
||||||
|
cost = cur
|
||||||
|
}
|
||||||
|
user.Materials[mat.MaterialId] = cur - cost
|
||||||
|
}
|
||||||
|
|
||||||
|
if costFunc, ok := s.catalog.ActiveSkillCostByRarity[cm.RarityType]; ok {
|
||||||
|
goldCost := costFunc.Evaluate(lvl + 1)
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skill.UserCostumeUuid = req.UserCostumeUuid
|
||||||
|
skill.Level = currentLevel + addCount
|
||||||
|
skill.LatestVersion = nowMillis
|
||||||
|
user.CostumeActiveSkills[req.UserCostumeUuid] = skill
|
||||||
|
log.Printf("[CostumeService] EnhanceActiveSkill: costumeId=%d level %d -> %d", costume.CostumeId, currentLevel, skill.Level)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("costume enhance active skill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, activeSkillDiffTables))
|
||||||
|
|
||||||
|
return &pb.EnhanceActiveSkillResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CostumeServiceServer) LimitBreak(ctx context.Context, req *pb.LimitBreakRequest) (*pb.LimitBreakResponse, error) {
|
||||||
|
log.Printf("[CostumeService] LimitBreak: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
costume, ok := user.Costumes[req.UserCostumeUuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CostumeService] LimitBreak: costume uuid=%s not found", req.UserCostumeUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if costume.LimitBreakCount >= s.config.CostumeLimitBreakAvailableCount {
|
||||||
|
log.Printf("[CostumeService] LimitBreak: already at max limit break %d", costume.LimitBreakCount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cm, ok := s.catalog.Costumes[costume.CostumeId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[CostumeService] LimitBreak: costume master id=%d not found", costume.CostumeId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalMaterialCount := int32(0)
|
||||||
|
for materialId, count := range req.Materials {
|
||||||
|
cur := user.Materials[materialId]
|
||||||
|
if cur < count {
|
||||||
|
log.Printf("[CostumeService] LimitBreak: insufficient material id=%d have=%d need=%d", materialId, cur, count)
|
||||||
|
count = cur
|
||||||
|
}
|
||||||
|
user.Materials[materialId] = cur - count
|
||||||
|
totalMaterialCount += count
|
||||||
|
}
|
||||||
|
|
||||||
|
if costFunc, ok := s.catalog.LimitBreakCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
|
||||||
|
goldCost := costFunc.Evaluate(totalMaterialCount)
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
|
||||||
|
log.Printf("[CostumeService] LimitBreak: gold cost=%d", goldCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
costume.LimitBreakCount++
|
||||||
|
costume.LatestVersion = nowMillis
|
||||||
|
user.Costumes[req.UserCostumeUuid] = costume
|
||||||
|
log.Printf("[CostumeService] LimitBreak: costumeId=%d limitBreak -> %d", costume.CostumeId, costume.LimitBreakCount)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("costume limit break: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, costumeDiffTables))
|
||||||
|
|
||||||
|
return &pb.LimitBreakResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DataServiceServer struct {
|
||||||
|
pb.UnimplementedDataServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDataServiceServer(users store.UserRepository, sessions store.SessionRepository) *DataServiceServer {
|
||||||
|
return &DataServiceServer{users: users, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DataServiceServer) GetLatestMasterDataVersion(ctx context.Context, _ *emptypb.Empty) (*pb.MasterDataGetLatestVersionResponse, error) {
|
||||||
|
log.Printf("[DataService] GetLatestMasterDataVersion")
|
||||||
|
return &pb.MasterDataGetLatestVersionResponse{
|
||||||
|
LatestMasterDataVersion: "20240404193219",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DataServiceServer) GetUserDataNameV2(ctx context.Context, _ *emptypb.Empty) (*pb.UserDataGetNameResponseV2, error) {
|
||||||
|
log.Printf("[DataService] GetUserDataNameV2")
|
||||||
|
return &pb.UserDataGetNameResponseV2{
|
||||||
|
TableNameList: []*pb.TableNameList{
|
||||||
|
{TableName: defaultTableNames()},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DataServiceServer) GetUserData(ctx context.Context, req *pb.UserDataGetRequest) (*pb.UserDataGetResponse, error) {
|
||||||
|
log.Printf("[DataService] GetUserData: tables=%v", req.TableName)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, err := s.users.SnapshotUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("snapshot user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaults := userdata.FirstEntranceClientTableMap(user)
|
||||||
|
result := userdata.SelectTables(defaults, req.TableName)
|
||||||
|
return &pb.UserDataGetResponse{
|
||||||
|
UserDataJson: result,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultTableNames() []string {
|
||||||
|
return []string{
|
||||||
|
"IUser",
|
||||||
|
"IUserApple",
|
||||||
|
"IUserAutoSaleSettingDetail",
|
||||||
|
"IUserBeginnerCampaign",
|
||||||
|
"IUserBigHuntMaxScore",
|
||||||
|
"IUserBigHuntProgressStatus",
|
||||||
|
"IUserBigHuntScheduleMaxScore",
|
||||||
|
"IUserBigHuntStatus",
|
||||||
|
"IUserBigHuntWeeklyMaxScore",
|
||||||
|
"IUserBigHuntWeeklyStatus",
|
||||||
|
"IUserCageOrnamentReward",
|
||||||
|
"IUserCharacter",
|
||||||
|
"IUserCharacterBoard",
|
||||||
|
"IUserCharacterBoardAbility",
|
||||||
|
"IUserCharacterBoardCompleteReward",
|
||||||
|
"IUserCharacterBoardStatusUp",
|
||||||
|
"IUserCharacterCostumeLevelBonus",
|
||||||
|
"IUserCharacterRebirth",
|
||||||
|
"IUserCharacterViewerField",
|
||||||
|
"IUserComebackCampaign",
|
||||||
|
"IUserCompanion",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserContentsStory",
|
||||||
|
"IUserCostume",
|
||||||
|
"IUserCostumeActiveSkill",
|
||||||
|
"IUserCostumeAwakenStatusUp",
|
||||||
|
"IUserCostumeLevelBonusReleaseStatus",
|
||||||
|
"IUserCostumeLotteryEffect",
|
||||||
|
"IUserCostumeLotteryEffectAbility",
|
||||||
|
"IUserCostumeLotteryEffectPending",
|
||||||
|
"IUserCostumeLotteryEffectStatusUp",
|
||||||
|
"IUserDeck",
|
||||||
|
"IUserDeckCharacter",
|
||||||
|
"IUserDeckCharacterDressupCostume",
|
||||||
|
"IUserDeckLimitContentRestricted",
|
||||||
|
"IUserDeckPartsGroup",
|
||||||
|
"IUserDeckSubWeaponGroup",
|
||||||
|
"IUserDeckTypeNote",
|
||||||
|
"IUserDokan",
|
||||||
|
"IUserEventQuestDailyGroupCompleteReward",
|
||||||
|
"IUserEventQuestGuerrillaFreeOpen",
|
||||||
|
"IUserEventQuestLabyrinthSeason",
|
||||||
|
"IUserEventQuestLabyrinthStage",
|
||||||
|
"IUserEventQuestProgressStatus",
|
||||||
|
"IUserEventQuestTowerAccumulationReward",
|
||||||
|
"IUserExplore",
|
||||||
|
"IUserExploreScore",
|
||||||
|
"IUserExtraQuestProgressStatus",
|
||||||
|
"IUserFacebook",
|
||||||
|
"IUserGem",
|
||||||
|
"IUserGimmick",
|
||||||
|
"IUserGimmickOrnamentProgress",
|
||||||
|
"IUserGimmickSequence",
|
||||||
|
"IUserGimmickUnlock",
|
||||||
|
"IUserImportantItem",
|
||||||
|
"IUserLimitedOpen",
|
||||||
|
// "IUserLogin",
|
||||||
|
"IUserLoginBonus",
|
||||||
|
"IUserMainQuestFlowStatus",
|
||||||
|
"IUserMainQuestMainFlowStatus",
|
||||||
|
"IUserMainQuestProgressStatus",
|
||||||
|
"IUserMainQuestReplayFlowStatus",
|
||||||
|
"IUserMainQuestSeasonRoute",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserMission",
|
||||||
|
"IUserMissionCompletionProgress",
|
||||||
|
"IUserMissionPassPoint",
|
||||||
|
"IUserMovie",
|
||||||
|
"IUserNaviCutIn",
|
||||||
|
"IUserOmikuji",
|
||||||
|
"IUserParts",
|
||||||
|
"IUserPartsGroupNote",
|
||||||
|
"IUserPartsPreset",
|
||||||
|
"IUserPartsPresetTag",
|
||||||
|
"IUserPartsStatusSub",
|
||||||
|
"IUserPortalCageStatus",
|
||||||
|
"IUserPossessionAutoConvert",
|
||||||
|
"IUserPremiumItem",
|
||||||
|
"IUserProfile",
|
||||||
|
"IUserPvpDefenseDeck",
|
||||||
|
"IUserPvpStatus",
|
||||||
|
"IUserPvpWeeklyResult",
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserQuestAutoOrbit",
|
||||||
|
"IUserQuestLimitContentStatus",
|
||||||
|
"IUserQuestMission",
|
||||||
|
"IUserQuestReplayFlowRewardGroup",
|
||||||
|
"IUserQuestSceneChoice",
|
||||||
|
"IUserQuestSceneChoiceHistory",
|
||||||
|
// "IUserSetting",
|
||||||
|
"IUserShopItem",
|
||||||
|
"IUserShopReplaceable",
|
||||||
|
"IUserShopReplaceableLineup",
|
||||||
|
"IUserSideStoryQuest",
|
||||||
|
"IUserSideStoryQuestSceneProgressStatus",
|
||||||
|
"IUserStatus",
|
||||||
|
"IUserThought",
|
||||||
|
"IUserTripleDeck",
|
||||||
|
"IUserTutorialProgress",
|
||||||
|
"IUserWeapon",
|
||||||
|
"IUserWeaponAbility",
|
||||||
|
"IUserWeaponAwaken",
|
||||||
|
"IUserWeaponNote",
|
||||||
|
"IUserWeaponSkill",
|
||||||
|
"IUserWeaponStory",
|
||||||
|
"IUserWebviewPanelMission",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeckServiceServer struct {
|
||||||
|
pb.UnimplementedDeckServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeckServiceServer(users store.UserRepository, sessions store.SessionRepository) *DeckServiceServer {
|
||||||
|
return &DeckServiceServer{users: users, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeckServiceServer) UpdateName(ctx context.Context, req *pb.UpdateNameRequest) (*pb.UpdateNameResponse, error) {
|
||||||
|
log.Printf("[DeckService] UpdateName: deckType=%d deckNumber=%d name=%q", req.DeckType, req.UserDeckNumber, req.Name)
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
deckKey := store.DeckKey{DeckType: model.DeckType(req.DeckType), UserDeckNumber: req.UserDeckNumber}
|
||||||
|
deck := user.Decks[deckKey]
|
||||||
|
deck.Name = req.Name
|
||||||
|
user.Decks[deckKey] = deck
|
||||||
|
})
|
||||||
|
|
||||||
|
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserDeck"})
|
||||||
|
return &pb.UpdateNameResponse{
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(result),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeckServiceServer) RefreshDeckPower(ctx context.Context, req *pb.RefreshDeckPowerRequest) (*pb.RefreshDeckPowerResponse, error) {
|
||||||
|
log.Printf("[DeckService] RefreshDeckPower: deckType=%d deckNumber=%d", req.DeckType, req.UserDeckNumber)
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
if req.DeckPower == nil {
|
||||||
|
log.Printf("[DeckService] RefreshDeckPower: deckPower is nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dt := model.DeckType(req.DeckType)
|
||||||
|
deckKey := store.DeckKey{DeckType: dt, UserDeckNumber: req.UserDeckNumber}
|
||||||
|
deck, ok := user.Decks[deckKey]
|
||||||
|
if !ok {
|
||||||
|
log.Fatalf("[DeckService] RefreshDeckPower: deck not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
deck.Power = req.DeckPower.Power
|
||||||
|
user.Decks[deckKey] = deck
|
||||||
|
|
||||||
|
for _, cp := range []*pb.DeckCharacterPower{
|
||||||
|
req.DeckPower.DeckCharacterPower01,
|
||||||
|
req.DeckPower.DeckCharacterPower02,
|
||||||
|
req.DeckPower.DeckCharacterPower03,
|
||||||
|
} {
|
||||||
|
if cp == nil || cp.UserDeckCharacterUuid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if dc, ok := user.DeckCharacters[cp.UserDeckCharacterUuid]; ok {
|
||||||
|
dc.Power = cp.Power
|
||||||
|
user.DeckCharacters[cp.UserDeckCharacterUuid] = dc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
note := user.DeckTypeNotes[dt]
|
||||||
|
if req.DeckPower.Power > note.MaxDeckPower {
|
||||||
|
note.DeckType = dt
|
||||||
|
note.MaxDeckPower = req.DeckPower.Power
|
||||||
|
user.DeckTypeNotes[dt] = note
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
|
||||||
|
"IUserDeck", "IUserDeckCharacter", "IUserDeckTypeNote",
|
||||||
|
})
|
||||||
|
return &pb.RefreshDeckPowerResponse{
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(result),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeckServiceServer) RefreshMultiDeckPower(ctx context.Context, req *pb.RefreshMultiDeckPowerRequest) (*pb.RefreshMultiDeckPowerResponse, error) {
|
||||||
|
log.Printf("[DeckService] RefreshMultiDeckPower: %d entries", len(req.DeckPowerInfo))
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
for _, info := range req.DeckPowerInfo {
|
||||||
|
if info.DeckPower == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dt := model.DeckType(info.DeckType)
|
||||||
|
deckKey := store.DeckKey{DeckType: dt, UserDeckNumber: info.UserDeckNumber}
|
||||||
|
deck, ok := user.Decks[deckKey]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[DeckService] RefreshMultiDeckPower: deck not found deckType=%d deckNumber=%d", info.DeckType, info.UserDeckNumber)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
deck.Power = info.DeckPower.Power
|
||||||
|
user.Decks[deckKey] = deck
|
||||||
|
|
||||||
|
for _, cp := range []*pb.DeckCharacterPower{
|
||||||
|
info.DeckPower.DeckCharacterPower01,
|
||||||
|
info.DeckPower.DeckCharacterPower02,
|
||||||
|
info.DeckPower.DeckCharacterPower03,
|
||||||
|
} {
|
||||||
|
if cp == nil || cp.UserDeckCharacterUuid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dc, ok := user.DeckCharacters[cp.UserDeckCharacterUuid]; ok {
|
||||||
|
dc.Power = cp.Power
|
||||||
|
user.DeckCharacters[cp.UserDeckCharacterUuid] = dc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
note := user.DeckTypeNotes[dt]
|
||||||
|
if info.DeckPower.Power > note.MaxDeckPower {
|
||||||
|
note.DeckType = dt
|
||||||
|
note.MaxDeckPower = info.DeckPower.Power
|
||||||
|
user.DeckTypeNotes[dt] = note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
|
||||||
|
"IUserDeck", "IUserDeckCharacter", "IUserDeckTypeNote",
|
||||||
|
})
|
||||||
|
return &pb.RefreshMultiDeckPowerResponse{
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(result),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deckSlotsFromProto(deck *pb.Deck) []store.DeckCharacterInput {
|
||||||
|
slots := make([]store.DeckCharacterInput, 3)
|
||||||
|
for i, ch := range []*pb.DeckCharacter{deck.Character01, deck.Character02, deck.Character03} {
|
||||||
|
if ch == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slots[i] = store.DeckCharacterInput{
|
||||||
|
UserCostumeUuid: ch.UserCostumeUuid,
|
||||||
|
MainUserWeaponUuid: ch.MainUserWeaponUuid,
|
||||||
|
SubWeaponUuids: ch.SubUserWeaponUuid,
|
||||||
|
PartsUuids: ch.UserPartsUuid,
|
||||||
|
UserCompanionUuid: ch.UserCompanionUuid,
|
||||||
|
UserThoughtUuid: ch.UserThoughtUuid,
|
||||||
|
DressupCostumeId: ch.DressupCostumeId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeckServiceServer) ReplaceDeck(ctx context.Context, req *pb.ReplaceDeckRequest) (*pb.ReplaceDeckResponse, error) {
|
||||||
|
log.Printf("[DeckService] ReplaceDeck: deckType=%d deckNumber=%d", req.DeckType, req.UserDeckNumber)
|
||||||
|
if req.Deck != nil {
|
||||||
|
for i, ch := range []*pb.DeckCharacter{req.Deck.Character01, req.Deck.Character02, req.Deck.Character03} {
|
||||||
|
if ch == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("[DeckService] ReplaceDeck slot %d: costume=%s mainWeapon=%s subWeapons=%v companion=%s thought=%s",
|
||||||
|
i+1, ch.UserCostumeUuid, ch.MainUserWeaponUuid, ch.SubUserWeaponUuid, ch.UserCompanionUuid, ch.UserThoughtUuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
|
||||||
|
oldUser, _ := s.users.SnapshotUser(userId)
|
||||||
|
tracker := userdata.NewDeleteTracker().
|
||||||
|
Track("IUserDeckSubWeaponGroup", oldUser, userdata.DeckSubWeaponRecords,
|
||||||
|
[]string{"userId", "userDeckCharacterUuid", "userWeaponUuid"}).
|
||||||
|
Track("IUserDeckPartsGroup", oldUser, userdata.DeckPartsGroupRecords,
|
||||||
|
[]string{"userId", "userDeckCharacterUuid", "userPartsUuid"}).
|
||||||
|
Track("IUserDeckCharacterDressupCostume", oldUser, userdata.DeckDressupCostumeRecords,
|
||||||
|
[]string{"userId", "userDeckCharacterUuid"})
|
||||||
|
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
if req.Deck == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.ApplyDeckReplacement(user, model.DeckType(req.DeckType), req.UserDeckNumber, deckSlotsFromProto(req.Deck), gametime.NowMillis())
|
||||||
|
})
|
||||||
|
|
||||||
|
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
|
||||||
|
"IUserDeck", "IUserDeckCharacter", "IUserDeckSubWeaponGroup", "IUserDeckPartsGroup",
|
||||||
|
"IUserDeckCharacterDressupCostume",
|
||||||
|
})
|
||||||
|
return &pb.ReplaceDeckResponse{
|
||||||
|
DiffUserData: tracker.Apply(user, result),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeckServiceServer) ReplaceTripleDeck(ctx context.Context, req *pb.ReplaceTripleDeckRequest) (*pb.ReplaceTripleDeckResponse, error) {
|
||||||
|
log.Printf("[DeckService] ReplaceTripleDeck: deckType=%d deckNumber=%d", req.DeckType, req.UserDeckNumber)
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
|
||||||
|
oldUser, _ := s.users.SnapshotUser(userId)
|
||||||
|
tracker := userdata.NewDeleteTracker().
|
||||||
|
Track("IUserDeckSubWeaponGroup", oldUser, userdata.DeckSubWeaponRecords,
|
||||||
|
[]string{"userId", "userDeckCharacterUuid", "userWeaponUuid"}).
|
||||||
|
Track("IUserDeckPartsGroup", oldUser, userdata.DeckPartsGroupRecords,
|
||||||
|
[]string{"userId", "userDeckCharacterUuid", "userPartsUuid"}).
|
||||||
|
Track("IUserDeckCharacterDressupCostume", oldUser, userdata.DeckDressupCostumeRecords,
|
||||||
|
[]string{"userId", "userDeckCharacterUuid"})
|
||||||
|
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
for idx, detail := range []*pb.DeckDetail{req.DeckDetail01, req.DeckDetail02, req.DeckDetail03} {
|
||||||
|
if detail == nil || detail.Deck == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("[DeckService] ReplaceTripleDeck detail %d: deckType=%d deckNumber=%d", idx+1, detail.DeckType, detail.UserDeckNumber)
|
||||||
|
if detail.Deck != nil {
|
||||||
|
for i, ch := range []*pb.DeckCharacter{detail.Deck.Character01, detail.Deck.Character02, detail.Deck.Character03} {
|
||||||
|
if ch == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("[DeckService] ReplaceTripleDeck detail %d slot %d: costume=%s mainWeapon=%s subWeapons=%v companion=%s thought=%s",
|
||||||
|
idx+1, i+1, ch.UserCostumeUuid, ch.MainUserWeaponUuid, ch.SubUserWeaponUuid, ch.UserCompanionUuid, ch.UserThoughtUuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.ApplyDeckReplacement(user, model.DeckType(detail.DeckType), detail.UserDeckNumber, deckSlotsFromProto(detail.Deck), nowMillis)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
|
||||||
|
"IUserDeck", "IUserDeckCharacter", "IUserDeckSubWeaponGroup", "IUserDeckPartsGroup",
|
||||||
|
"IUserDeckCharacterDressupCostume",
|
||||||
|
})
|
||||||
|
return &pb.ReplaceTripleDeckResponse{
|
||||||
|
DiffUserData: tracker.Apply(user, result),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DokanServiceServer struct {
|
||||||
|
pb.UnimplementedDokanServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDokanServiceServer(users store.UserRepository, sessions store.SessionRepository) *DokanServiceServer {
|
||||||
|
return &DokanServiceServer{users: users, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DokanServiceServer) RegisterDokanConfirmed(ctx context.Context, req *pb.RegisterDokanConfirmedRequest) (*pb.RegisterDokanConfirmedResponse, error) {
|
||||||
|
log.Printf("[DokanService] RegisterDokanConfirmed: dokanIds=%v", req.DokanId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
for _, id := range req.DokanId {
|
||||||
|
user.DokanConfirmed[id] = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserDokan"}))
|
||||||
|
|
||||||
|
return &pb.RegisterDokanConfirmedResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
exploreStaminaRecovery = 1000 // millivalue added on finish
|
||||||
|
exploreRewardMaterialId = 100001
|
||||||
|
exploreRewardBaseCount = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
var exploreDiffTables = []string{
|
||||||
|
"IUserExplore",
|
||||||
|
"IUserExploreScore",
|
||||||
|
}
|
||||||
|
|
||||||
|
var exploreFinishDiffTables = []string{
|
||||||
|
"IUserExplore",
|
||||||
|
"IUserExploreScore",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserStatus",
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExploreServiceServer struct {
|
||||||
|
pb.UnimplementedExploreServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.ExploreCatalog
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExploreServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.ExploreCatalog) *ExploreServiceServer {
|
||||||
|
return &ExploreServiceServer{users: users, sessions: sessions, catalog: catalog}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExploreServiceServer) StartExplore(ctx context.Context, req *pb.StartExploreRequest) (*pb.StartExploreResponse, error) {
|
||||||
|
log.Printf("[ExploreService] StartExplore: exploreId=%d useConsumableItemId=%d", req.ExploreId, req.UseConsumableItemId)
|
||||||
|
|
||||||
|
if _, ok := s.catalog.Explores[req.ExploreId]; !ok {
|
||||||
|
return nil, fmt.Errorf("explore id=%d not found", req.ExploreId)
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
explore := s.catalog.Explores[req.ExploreId]
|
||||||
|
if req.UseConsumableItemId > 0 && explore.ConsumeItemCount > 0 {
|
||||||
|
cur := user.ConsumableItems[req.UseConsumableItemId]
|
||||||
|
user.ConsumableItems[req.UseConsumableItemId] = cur - explore.ConsumeItemCount
|
||||||
|
log.Printf("[ExploreService] StartExplore: consumed item=%d count=%d remaining=%d", req.UseConsumableItemId, explore.ConsumeItemCount, user.ConsumableItems[req.UseConsumableItemId])
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Explore = store.ExploreState{
|
||||||
|
PlayingExploreId: req.ExploreId,
|
||||||
|
IsUseExploreTicket: false,
|
||||||
|
LatestPlayDatetime: nowMillis,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("start explore: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, exploreDiffTables))
|
||||||
|
|
||||||
|
return &pb.StartExploreResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExploreServiceServer) FinishExplore(ctx context.Context, req *pb.FinishExploreRequest) (*pb.FinishExploreResponse, error) {
|
||||||
|
log.Printf("[ExploreService] FinishExplore: exploreId=%d score=%d", req.ExploreId, req.Score)
|
||||||
|
|
||||||
|
explore, ok := s.catalog.Explores[req.ExploreId]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("explore id=%d not found", req.ExploreId)
|
||||||
|
}
|
||||||
|
|
||||||
|
assetGradeIconId := s.catalog.GradeForScore(req.ExploreId, req.Score)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
rewardCount := int32(exploreRewardBaseCount) * explore.RewardLotteryCount
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
existing, exists := user.ExploreScores[req.ExploreId]
|
||||||
|
if !exists || req.Score > existing.MaxScore {
|
||||||
|
user.ExploreScores[req.ExploreId] = store.ExploreScoreState{
|
||||||
|
ExploreId: req.ExploreId,
|
||||||
|
MaxScore: req.Score,
|
||||||
|
MaxScoreUpdateDatetime: nowMillis,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Explore = store.ExploreState{
|
||||||
|
PlayingExploreId: 0,
|
||||||
|
IsUseExploreTicket: false,
|
||||||
|
LatestPlayDatetime: user.Explore.LatestPlayDatetime,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Status.StaminaMilliValue += exploreStaminaRecovery
|
||||||
|
user.Status.StaminaUpdateDatetime = nowMillis
|
||||||
|
user.Status.LatestVersion = nowMillis
|
||||||
|
log.Printf("[ExploreService] FinishExplore: stamina +%d -> %d", exploreStaminaRecovery, user.Status.StaminaMilliValue)
|
||||||
|
|
||||||
|
user.Materials[exploreRewardMaterialId] += rewardCount
|
||||||
|
log.Printf("[ExploreService] FinishExplore: granted material=%d count=%d", exploreRewardMaterialId, rewardCount)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("finish explore: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, exploreFinishDiffTables))
|
||||||
|
|
||||||
|
rewards := []*pb.ExploreReward{
|
||||||
|
{
|
||||||
|
PossessionType: int32(model.PossessionTypeMaterial),
|
||||||
|
PossessionId: exploreRewardMaterialId,
|
||||||
|
Count: rewardCount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.FinishExploreResponse{
|
||||||
|
AcquireStaminaCount: exploreStaminaRecovery,
|
||||||
|
ExploreReward: rewards,
|
||||||
|
AssetGradeIconId: assetGradeIconId,
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExploreServiceServer) RetireExplore(ctx context.Context, req *pb.RetireExploreRequest) (*pb.RetireExploreResponse, error) {
|
||||||
|
log.Printf("[ExploreService] RetireExplore: exploreId=%d", req.ExploreId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.Explore = store.ExploreState{
|
||||||
|
PlayingExploreId: 0,
|
||||||
|
IsUseExploreTicket: false,
|
||||||
|
LatestPlayDatetime: user.Explore.LatestPlayDatetime,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("retire explore: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserExplore"}))
|
||||||
|
|
||||||
|
return &pb.RetireExploreResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
|
||||||
|
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FriendServiceServer struct {
|
||||||
|
pb.UnimplementedFriendServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFriendServiceServer(users store.UserRepository, sessions store.SessionRepository) *FriendServiceServer {
|
||||||
|
return &FriendServiceServer{users: users, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FriendServiceServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
|
||||||
|
log.Printf("[FriendService] GetUser: playerId=%d", req.PlayerId)
|
||||||
|
return &pb.GetUserResponse{DiffUserData: userdata.EmptyDiff()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FriendServiceServer) GetFriendList(ctx context.Context, req *pb.GetFriendListRequest) (*pb.GetFriendListResponse, error) {
|
||||||
|
log.Printf("[FriendService] GetFriendList")
|
||||||
|
return &pb.GetFriendListResponse{
|
||||||
|
FriendUser: []*pb.FriendUser{},
|
||||||
|
SendCheerCount: 0,
|
||||||
|
ReceivedCheerCount: 0,
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FriendServiceServer) GetFriendRequestList(ctx context.Context, req *emptypb.Empty) (*pb.GetFriendRequestListResponse, error) {
|
||||||
|
log.Printf("[FriendService] GetFriendRequestList")
|
||||||
|
return &pb.GetFriendRequestListResponse{
|
||||||
|
User: []*pb.User{},
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FriendServiceServer) SearchRecommendedUsers(ctx context.Context, req *emptypb.Empty) (*pb.SearchRecommendedUsersResponse, error) {
|
||||||
|
log.Printf("[FriendService] SearchRecommendedUsers")
|
||||||
|
return &pb.SearchRecommendedUsersResponse{
|
||||||
|
Users: []*pb.User{},
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gacha"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
|
||||||
|
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
var gachaDiffTables = []string{
|
||||||
|
"IUserGem",
|
||||||
|
"IUserCostume",
|
||||||
|
"IUserWeapon",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserCostumeActiveSkill",
|
||||||
|
"IUserWeaponNote",
|
||||||
|
"IUserWeaponSkill",
|
||||||
|
"IUserWeaponAbility",
|
||||||
|
"IUserWeaponStory",
|
||||||
|
"IUserCharacter",
|
||||||
|
"IUserMaterial",
|
||||||
|
}
|
||||||
|
|
||||||
|
type GachaServiceServer struct {
|
||||||
|
pb.UnimplementedGachaServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
gacha store.GachaRepository
|
||||||
|
handler *gacha.GachaHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGachaServiceServer(
|
||||||
|
users store.UserRepository,
|
||||||
|
sessions store.SessionRepository,
|
||||||
|
gachaRepo store.GachaRepository,
|
||||||
|
handler *gacha.GachaHandler,
|
||||||
|
) *GachaServiceServer {
|
||||||
|
return &GachaServiceServer{
|
||||||
|
users: users,
|
||||||
|
sessions: sessions,
|
||||||
|
gacha: gachaRepo,
|
||||||
|
handler: handler,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GachaServiceServer) GetGachaList(ctx context.Context, req *pb.GetGachaListRequest) (*pb.GetGachaListResponse, error) {
|
||||||
|
log.Printf("[GachaService] GetGachaList: labels=%v", req.GachaLabelType)
|
||||||
|
|
||||||
|
catalog, _ := s.gacha.SnapshotCatalog()
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
user, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.EnsureMaps()
|
||||||
|
s.autoConvertExpiredMedals(user, catalog, nowMillis)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gachaList := make([]*pb.Gacha, 0, len(catalog))
|
||||||
|
for _, entry := range catalog {
|
||||||
|
if !matchesGachaLabel(req.GachaLabelType, entry.GachaLabelType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.GachaLabelType == model.GachaLabelPortalCage || entry.GachaLabelType == model.GachaLabelRecycle {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bs := user.Gacha.BannerStates[entry.GachaId]
|
||||||
|
gachaList = append(gachaList, toProtoGacha(entry, &bs))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.GetGachaListResponse{
|
||||||
|
Gacha: gachaList,
|
||||||
|
ConvertedGachaMedal: toProtoConvertedGachaMedal(user.Gacha.ConvertedGachaMedal),
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GachaServiceServer) autoConvertExpiredMedals(user *store.UserState, catalog []store.GachaCatalogEntry, nowMillis int64) {
|
||||||
|
for _, entry := range catalog {
|
||||||
|
if entry.GachaMedalId == 0 || entry.EndDatetime == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nowMillis < entry.EndDatetime {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bs, exists := user.Gacha.BannerStates[entry.GachaId]
|
||||||
|
if !exists || bs.MedalCount <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
medalInfo, ok := s.handler.MedalInfo[entry.GachaId]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
conversionRate := medalInfo.ConversionRate
|
||||||
|
if conversionRate <= 0 {
|
||||||
|
conversionRate = 1
|
||||||
|
}
|
||||||
|
bookmarkCount := bs.MedalCount * conversionRate
|
||||||
|
|
||||||
|
user.ConsumableItems[medalInfo.ConsumableItemId] += bookmarkCount
|
||||||
|
|
||||||
|
user.Gacha.ConvertedGachaMedal.ConvertedMedalPossession = append(
|
||||||
|
user.Gacha.ConvertedGachaMedal.ConvertedMedalPossession,
|
||||||
|
store.ConsumableItemState{
|
||||||
|
ConsumableItemId: medalInfo.ConsumableItemId,
|
||||||
|
Count: bookmarkCount,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
originalCount := bs.MedalCount
|
||||||
|
bs.MedalCount = 0
|
||||||
|
user.Gacha.BannerStates[entry.GachaId] = bs
|
||||||
|
|
||||||
|
log.Printf("[GachaService] auto-converted %d medals for gacha %d -> %d bookmarks (item %d)",
|
||||||
|
originalCount, entry.GachaId, bookmarkCount, medalInfo.ConsumableItemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GachaServiceServer) GetGacha(ctx context.Context, req *pb.GetGachaRequest) (*pb.GetGachaResponse, error) {
|
||||||
|
log.Printf("[GachaService] GetGacha: ids=%v", req.GachaId)
|
||||||
|
|
||||||
|
catalog, _ := s.gacha.SnapshotCatalog()
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, err := s.users.SnapshotUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("snapshot user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
byId := make(map[int32]*pb.Gacha, len(req.GachaId))
|
||||||
|
for _, wantedId := range req.GachaId {
|
||||||
|
for _, entry := range catalog {
|
||||||
|
if entry.GachaId == wantedId {
|
||||||
|
bs := user.Gacha.BannerStates[entry.GachaId]
|
||||||
|
byId[wantedId] = toProtoGacha(entry, &bs)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.GetGachaResponse{
|
||||||
|
Gacha: byId,
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb.DrawResponse, error) {
|
||||||
|
log.Printf("[GachaService] Draw: gachaId=%d phaseId=%d execCount=%d", req.GachaId, req.GachaPricePhaseId, req.ExecCount)
|
||||||
|
|
||||||
|
catalog, _ := s.gacha.SnapshotCatalog()
|
||||||
|
entry := findCatalogEntry(catalog, req.GachaId)
|
||||||
|
if entry == nil {
|
||||||
|
return nil, fmt.Errorf("gacha %d not found", req.GachaId)
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
execCount := req.ExecCount
|
||||||
|
if execCount <= 0 {
|
||||||
|
execCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var drawResult *gacha.DrawResult
|
||||||
|
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
var drawErr error
|
||||||
|
drawResult, drawErr = s.handler.HandleDraw(user, *entry, req.GachaPricePhaseId, execCount)
|
||||||
|
if drawErr != nil {
|
||||||
|
log.Printf("[GachaService] Draw error: %v", drawErr)
|
||||||
|
drawResult = &gacha.DrawResult{}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, item := range drawResult.Items {
|
||||||
|
if bonus, ok := drawResult.BonusItems[i]; ok {
|
||||||
|
log.Printf("[GachaService] drawn[%d]: type=%d id=%d rarity=%d + bonus type=%d id=%d rarity=%d",
|
||||||
|
i, item.PossessionType, item.PossessionId, item.RarityType,
|
||||||
|
bonus.PossessionType, bonus.PossessionId, bonus.RarityType)
|
||||||
|
} else {
|
||||||
|
log.Printf("[GachaService] drawn[%d]: type=%d id=%d rarity=%d",
|
||||||
|
i, item.PossessionType, item.PossessionId, item.RarityType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gachaResults := make([]*pb.DrawGachaOddsItem, 0, len(drawResult.Items))
|
||||||
|
dupMap := make(map[int]gacha.DuplicateInfo)
|
||||||
|
for _, d := range drawResult.DuplicateInfos {
|
||||||
|
dupMap[d.Index] = d
|
||||||
|
}
|
||||||
|
bonusDupMap := make(map[int]gacha.DuplicateInfo)
|
||||||
|
for _, d := range drawResult.BonusDuplicateInfos {
|
||||||
|
bonusDupMap[d.Index] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
costumePT := int32(model.PossessionTypeCostume)
|
||||||
|
weaponPT := int32(model.PossessionTypeWeapon)
|
||||||
|
isMaterialDraw := model.IsMaterialBanner(entry.GachaLabelType)
|
||||||
|
|
||||||
|
ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes))
|
||||||
|
for _, c := range updatedUser.Costumes {
|
||||||
|
ownedCostumes[c.CostumeId] = true
|
||||||
|
}
|
||||||
|
ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons))
|
||||||
|
for _, w := range updatedUser.Weapons {
|
||||||
|
ownedWeapons[w.WeaponId] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, item := range drawResult.Items {
|
||||||
|
isNew := !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser)
|
||||||
|
|
||||||
|
var oddsItem *pb.DrawGachaOddsItem
|
||||||
|
|
||||||
|
if isMaterialDraw {
|
||||||
|
oddsItem = &pb.DrawGachaOddsItem{
|
||||||
|
GachaItem: &pb.GachaItem{
|
||||||
|
PossessionType: item.PossessionType,
|
||||||
|
PossessionId: item.PossessionId,
|
||||||
|
Count: 1,
|
||||||
|
IsNew: isNew,
|
||||||
|
},
|
||||||
|
GachaItemBonus: &pb.GachaItem{},
|
||||||
|
}
|
||||||
|
} else if bonus, hasBonusWeapon := drawResult.BonusItems[i]; hasBonusWeapon {
|
||||||
|
oddsItem = &pb.DrawGachaOddsItem{
|
||||||
|
GachaItem: &pb.GachaItem{
|
||||||
|
PossessionType: costumePT,
|
||||||
|
PossessionId: item.PossessionId,
|
||||||
|
Count: 1,
|
||||||
|
IsNew: isNew,
|
||||||
|
},
|
||||||
|
GachaItemBonus: &pb.GachaItem{
|
||||||
|
PossessionType: weaponPT,
|
||||||
|
PossessionId: bonus.PossessionId,
|
||||||
|
Count: 1,
|
||||||
|
IsNew: !ownedWeapons[bonus.PossessionId],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
oddsItem = &pb.DrawGachaOddsItem{
|
||||||
|
GachaItem: &pb.GachaItem{
|
||||||
|
PossessionType: item.PossessionType,
|
||||||
|
PossessionId: item.PossessionId,
|
||||||
|
Count: 1,
|
||||||
|
IsNew: isNew,
|
||||||
|
},
|
||||||
|
GachaItemBonus: &pb.GachaItem{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if drawResult.MedalBonus > 0 && entry.MedalConsumableItemId != 0 {
|
||||||
|
oddsItem.MedalBonus = &pb.GachaBonus{
|
||||||
|
PossessionType: int32(model.PossessionTypeConsumableItem),
|
||||||
|
PossessionId: entry.MedalConsumableItemId,
|
||||||
|
Count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if dup, ok := dupMap[i]; ok {
|
||||||
|
applyDuplicationBonus(oddsItem, dup)
|
||||||
|
}
|
||||||
|
if bdup, ok := bonusDupMap[i]; ok {
|
||||||
|
applyDuplicationBonus(oddsItem, bdup)
|
||||||
|
}
|
||||||
|
|
||||||
|
gachaResults = append(gachaResults, oddsItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bonuses []*pb.GachaBonus
|
||||||
|
for _, b := range drawResult.Bonuses {
|
||||||
|
bonuses = append(bonuses, &pb.GachaBonus{
|
||||||
|
PossessionType: b.PossessionType,
|
||||||
|
PossessionId: b.PossessionId,
|
||||||
|
Count: b.Count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
bs := updatedUser.Gacha.BannerStates[entry.GachaId]
|
||||||
|
nextGacha := toProtoGacha(*entry, &bs)
|
||||||
|
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(
|
||||||
|
userdata.FullClientTableMap(updatedUser),
|
||||||
|
gachaDiffTables,
|
||||||
|
))
|
||||||
|
|
||||||
|
return &pb.DrawResponse{
|
||||||
|
NextGacha: nextGacha,
|
||||||
|
GachaResult: gachaResults,
|
||||||
|
GachaBonus: bonuses,
|
||||||
|
MenuGachaBadgeInfo: []*pb.MenuGachaBadgeInfo{},
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GachaServiceServer) ResetBoxGacha(ctx context.Context, req *pb.ResetBoxGachaRequest) (*pb.ResetBoxGachaResponse, error) {
|
||||||
|
log.Printf("[GachaService] ResetBoxGacha: gachaId=%d", req.GachaId)
|
||||||
|
|
||||||
|
catalog, _ := s.gacha.SnapshotCatalog()
|
||||||
|
entry := findCatalogEntry(catalog, req.GachaId)
|
||||||
|
if entry == nil {
|
||||||
|
return nil, fmt.Errorf("gacha %d not found", req.GachaId)
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
if resetErr := s.handler.HandleResetBox(user, *entry); resetErr != nil {
|
||||||
|
log.Printf("[GachaService] ResetBoxGacha error: %v", resetErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bs := updatedUser.Gacha.BannerStates[entry.GachaId]
|
||||||
|
|
||||||
|
return &pb.ResetBoxGachaResponse{
|
||||||
|
Gacha: toProtoGacha(*entry, &bs),
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GachaServiceServer) GetRewardGacha(ctx context.Context, req *emptypb.Empty) (*pb.GetRewardGachaResponse, error) {
|
||||||
|
log.Printf("[GachaService] GetRewardGacha")
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, err := s.users.SnapshotUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("snapshot user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxCount := s.handler.Config.RewardGachaDailyMaxCount
|
||||||
|
if maxCount <= 0 {
|
||||||
|
maxCount = model.DefaultDailyDrawLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
todayStart := gametime.StartOfDayMillis()
|
||||||
|
drawCount := user.Gacha.TodaysCurrentDrawCount
|
||||||
|
if user.Gacha.LastRewardDrawDate < todayStart {
|
||||||
|
drawCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.GetRewardGachaResponse{
|
||||||
|
Available: drawCount < maxCount,
|
||||||
|
TodaysCurrentDrawCount: drawCount,
|
||||||
|
DailyMaxCount: maxCount,
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GachaServiceServer) RewardDraw(ctx context.Context, req *pb.RewardDrawRequest) (*pb.RewardDrawResponse, error) {
|
||||||
|
log.Printf("[GachaService] RewardDraw: placement=%q reward=%q amount=%q", req.PlacementName, req.RewardName, req.RewardAmount)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
|
||||||
|
var items []gacha.DrawnItem
|
||||||
|
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
var drawErr error
|
||||||
|
items, drawErr = s.handler.HandleRewardDraw(user, 1)
|
||||||
|
if drawErr != nil {
|
||||||
|
log.Printf("[GachaService] RewardDraw error: %v", drawErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes))
|
||||||
|
for _, c := range updatedUser.Costumes {
|
||||||
|
ownedCostumes[c.CostumeId] = true
|
||||||
|
}
|
||||||
|
ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons))
|
||||||
|
for _, w := range updatedUser.Weapons {
|
||||||
|
ownedWeapons[w.WeaponId] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]*pb.RewardGachaItem, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
results = append(results, &pb.RewardGachaItem{
|
||||||
|
PossessionType: item.PossessionType,
|
||||||
|
PossessionId: item.PossessionId,
|
||||||
|
Count: 1,
|
||||||
|
IsNew: !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(updatedUser)
|
||||||
|
diff := userdata.BuildDiffFromTables(tables)
|
||||||
|
|
||||||
|
return &pb.RewardDrawResponse{
|
||||||
|
RewardGachaResult: results,
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCatalogEntry(catalog []store.GachaCatalogEntry, gachaId int32) *store.GachaCatalogEntry {
|
||||||
|
for i := range catalog {
|
||||||
|
if catalog[i].GachaId == gachaId {
|
||||||
|
return &catalog[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesGachaLabel(labels []int32, label int32) bool {
|
||||||
|
if len(labels) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, candidate := range labels {
|
||||||
|
if candidate == label {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProtoGacha(entry store.GachaCatalogEntry, bs *store.GachaBannerState) *pb.Gacha {
|
||||||
|
g := &pb.Gacha{
|
||||||
|
GachaId: entry.GachaId,
|
||||||
|
GachaLabelType: entry.GachaLabelType,
|
||||||
|
GachaModeType: entry.GachaModeType,
|
||||||
|
GachaAutoResetType: entry.GachaAutoResetType,
|
||||||
|
GachaAutoResetPeriod: entry.GachaAutoResetPeriod,
|
||||||
|
NextAutoResetDatetime: safeTimestamp(entry.NextAutoResetDatetime),
|
||||||
|
GachaUnlockCondition: []*pb.GachaUnlockCondition{{GachaUnlockConditionType: model.GachaUnlockNone, ConditionValue: 0}},
|
||||||
|
IsUserGachaUnlock: entry.IsUserGachaUnlock,
|
||||||
|
StartDatetime: safeTimestamp(entry.StartDatetime),
|
||||||
|
EndDatetime: safeTimestamp(entry.EndDatetime),
|
||||||
|
RelatedMainQuestChapterId: entry.RelatedMainQuestChapterId,
|
||||||
|
RelatedEventQuestChapterId: entry.RelatedEventQuestChapterId,
|
||||||
|
PromotionMovieAssetId: entry.PromotionMovieAssetId,
|
||||||
|
GachaMedalId: entry.GachaMedalId,
|
||||||
|
GachaDecorationType: entry.GachaDecorationType,
|
||||||
|
SortOrder: entry.SortOrder,
|
||||||
|
IsInactive: entry.IsInactive,
|
||||||
|
InformationId: entry.InformationId,
|
||||||
|
}
|
||||||
|
|
||||||
|
g.GachaPricePhase = buildProtoPricePhases(entry, bs)
|
||||||
|
|
||||||
|
promotionItems := buildProtoPromotionItems(entry)
|
||||||
|
|
||||||
|
switch entry.GachaModeType {
|
||||||
|
case model.GachaModeBox:
|
||||||
|
boxNumber := int32(1)
|
||||||
|
if bs != nil && bs.BoxNumber > 0 {
|
||||||
|
boxNumber = bs.BoxNumber
|
||||||
|
}
|
||||||
|
phaseId := int32(0)
|
||||||
|
if len(entry.PricePhases) > 0 {
|
||||||
|
phaseId = entry.PricePhases[0].PhaseId
|
||||||
|
}
|
||||||
|
g.GachaMode = &pb.Gacha_GachaModeBoxComposition{
|
||||||
|
GachaModeBoxComposition: &pb.GachaModeBoxComposition{
|
||||||
|
GachaBoxGroupId: entry.GroupId,
|
||||||
|
BoxNumber: boxNumber,
|
||||||
|
CurrentBoxNumber: boxNumber,
|
||||||
|
NaviCharacterCommentAssetName: "production",
|
||||||
|
GachaAssetName: entry.BannerAssetName,
|
||||||
|
GachaPricePhaseId: phaseId,
|
||||||
|
PromotionGachaOddsItem: promotionItems,
|
||||||
|
GachaDescriptionTextId: entry.DescriptionTextId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case model.GachaModeStepup:
|
||||||
|
stepNumber := int32(1)
|
||||||
|
loopCount := int32(0)
|
||||||
|
if bs != nil {
|
||||||
|
if bs.StepNumber > 0 {
|
||||||
|
stepNumber = bs.StepNumber
|
||||||
|
}
|
||||||
|
loopCount = bs.LoopCount
|
||||||
|
}
|
||||||
|
g.GachaMode = &pb.Gacha_GachaModeStepupComposition{
|
||||||
|
GachaModeStepupComposition: &pb.GachaModeStepupComposition{
|
||||||
|
GachaStepGroupId: entry.GroupId,
|
||||||
|
StepNumber: 1,
|
||||||
|
CurrentStepNumber: stepNumber,
|
||||||
|
NaviCharacterCommentAssetName: "production",
|
||||||
|
GachaAssetName: entry.BannerAssetName,
|
||||||
|
PromotionGachaOddsItem: promotionItems,
|
||||||
|
CurrentLoopCount: loopCount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
g.GachaMode = &pb.Gacha_GachaModeBasic{
|
||||||
|
GachaModeBasic: &pb.GachaModeBasic{
|
||||||
|
NaviCharacterCommentAssetName: "production",
|
||||||
|
GachaAssetName: entry.BannerAssetName,
|
||||||
|
PromotionGachaOddsItem: promotionItems,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildProtoPricePhases(entry store.GachaCatalogEntry, bs *store.GachaBannerState) []*pb.GachaPricePhase {
|
||||||
|
phases := make([]*pb.GachaPricePhase, 0, len(entry.PricePhases))
|
||||||
|
|
||||||
|
for _, p := range entry.PricePhases {
|
||||||
|
isEnabled := true
|
||||||
|
if entry.GachaModeType == model.GachaModeStepup && bs != nil {
|
||||||
|
currentStep := bs.StepNumber
|
||||||
|
if currentStep <= 0 {
|
||||||
|
currentStep = 1
|
||||||
|
}
|
||||||
|
isEnabled = p.StepNumber == currentStep
|
||||||
|
}
|
||||||
|
|
||||||
|
var bonuses []*pb.GachaBonus
|
||||||
|
for _, b := range p.Bonuses {
|
||||||
|
bonuses = append(bonuses, &pb.GachaBonus{
|
||||||
|
PossessionType: b.PossessionType,
|
||||||
|
PossessionId: b.PossessionId,
|
||||||
|
Count: b.Count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
limitExec := p.LimitExecCount
|
||||||
|
if limitExec <= 0 {
|
||||||
|
limitExec = 999
|
||||||
|
}
|
||||||
|
|
||||||
|
phases = append(phases, &pb.GachaPricePhase{
|
||||||
|
GachaPricePhaseId: p.PhaseId,
|
||||||
|
IsEnabled: isEnabled,
|
||||||
|
EndDatetime: safeTimestamp(entry.EndDatetime),
|
||||||
|
PriceType: p.PriceType,
|
||||||
|
PriceId: p.PriceId,
|
||||||
|
Price: p.Price,
|
||||||
|
RegularPrice: p.RegularPrice,
|
||||||
|
DrawCount: p.DrawCount,
|
||||||
|
LimitExecCount: limitExec,
|
||||||
|
EachMaxExecCount: p.DrawCount,
|
||||||
|
GachaBonus: bonuses,
|
||||||
|
GachaOddsFixedRarity: &pb.GachaOddsFixedRarity{
|
||||||
|
FixedRarityTypeLowerLimit: p.FixedRarityMin,
|
||||||
|
FixedCount: p.FixedCount,
|
||||||
|
},
|
||||||
|
GachaBadgeType: model.GachaBadgeTypeNone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return phases
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildProtoPromotionItems(entry store.GachaCatalogEntry) []*pb.GachaOddsItem {
|
||||||
|
if len(entry.PromotionItems) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
isMaterial := model.IsMaterialBanner(entry.GachaLabelType)
|
||||||
|
|
||||||
|
items := make([]*pb.GachaOddsItem, 0, len(entry.PromotionItems))
|
||||||
|
for i, pi := range entry.PromotionItems {
|
||||||
|
bonus := &pb.GachaItem{}
|
||||||
|
if !isMaterial && pi.BonusPossessionType != 0 {
|
||||||
|
bonus = &pb.GachaItem{
|
||||||
|
PossessionType: pi.BonusPossessionType,
|
||||||
|
PossessionId: pi.BonusPossessionId,
|
||||||
|
Count: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, &pb.GachaOddsItem{
|
||||||
|
GachaItem: &pb.GachaItem{
|
||||||
|
PossessionType: pi.PossessionType,
|
||||||
|
PossessionId: pi.PossessionId,
|
||||||
|
Count: 1,
|
||||||
|
PromotionOrder: int32(i + 1),
|
||||||
|
},
|
||||||
|
GachaItemBonus: bonus,
|
||||||
|
MaxDrawableCount: 999,
|
||||||
|
IsTarget: pi.IsTarget,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProtoConvertedGachaMedal(state store.ConvertedGachaMedalState) *pb.ConvertedGachaMedal {
|
||||||
|
items := make([]*pb.ConsumableItemPossession, 0, len(state.ConvertedMedalPossession))
|
||||||
|
for _, item := range state.ConvertedMedalPossession {
|
||||||
|
items = append(items, &pb.ConsumableItemPossession{
|
||||||
|
ConsumableItemId: item.ConsumableItemId,
|
||||||
|
Count: item.Count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
obtain := &pb.ConsumableItemPossession{
|
||||||
|
ConsumableItemId: 0,
|
||||||
|
Count: 0,
|
||||||
|
}
|
||||||
|
if state.ObtainPossession != nil {
|
||||||
|
obtain.ConsumableItemId = state.ObtainPossession.ConsumableItemId
|
||||||
|
obtain.Count = state.ObtainPossession.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.ConvertedGachaMedal{
|
||||||
|
ConvertedMedalPossession: items,
|
||||||
|
ObtainPossession: obtain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeTimestamp(unixMillis int64) *timestamppb.Timestamp {
|
||||||
|
if unixMillis == 0 {
|
||||||
|
return ×tamppb.Timestamp{Seconds: 0}
|
||||||
|
}
|
||||||
|
return timestamppb.New(time.UnixMilli(unixMillis))
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDuplicationBonus(oddsItem *pb.DrawGachaOddsItem, dup gacha.DuplicateInfo) {
|
||||||
|
if oddsItem.DuplicationBonusGrade == 0 {
|
||||||
|
oddsItem.DuplicationBonusGrade = dup.Grade
|
||||||
|
}
|
||||||
|
for _, b := range dup.Bonuses {
|
||||||
|
oddsItem.DuplicationBonus = append(oddsItem.DuplicationBonus, &pb.GachaBonus{
|
||||||
|
PossessionType: b.PossessionType,
|
||||||
|
PossessionId: b.PossessionId,
|
||||||
|
Count: b.Count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isOwnedByType(item gacha.DrawnItem, costumes, weapons map[int32]bool, user store.UserState) bool {
|
||||||
|
switch item.PossessionType {
|
||||||
|
case int32(model.PossessionTypeCostume):
|
||||||
|
return costumes[item.PossessionId]
|
||||||
|
case int32(model.PossessionTypeWeapon):
|
||||||
|
return weapons[item.PossessionId]
|
||||||
|
case int32(model.PossessionTypeMaterial):
|
||||||
|
return user.Materials[item.PossessionId] > 0
|
||||||
|
case int32(model.PossessionTypeWeaponEnhanced):
|
||||||
|
return user.ConsumableItems[item.PossessionId] > 0
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GameplayServiceServer struct {
|
||||||
|
pb.UnimplementedGamePlayServiceServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGameplayServiceServer() *GameplayServiceServer {
|
||||||
|
return &GameplayServiceServer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GameplayServiceServer) CheckBeforeGamePlay(ctx context.Context, req *pb.CheckBeforeGamePlayRequest) (*pb.CheckBeforeGamePlayResponse, error) {
|
||||||
|
log.Printf("[GamePlayService] CheckBeforeGamePlay: tr=%s voiceLang=%d textLang=%d",
|
||||||
|
req.Tr, req.VoiceClientSystemLanguageTypeId, req.TextClientSystemLanguageTypeId)
|
||||||
|
|
||||||
|
return &pb.CheckBeforeGamePlayResponse{
|
||||||
|
IsExistUnreadPop: false,
|
||||||
|
MenuGachaBadgeInfo: []*pb.MenuGachaBadgeInfo{},
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
|
||||||
|
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GiftServiceServer struct {
|
||||||
|
pb.UnimplementedGiftServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGiftServiceServer(users store.UserRepository, sessions store.SessionRepository) *GiftServiceServer {
|
||||||
|
return &GiftServiceServer{users: users, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GiftServiceServer) ReceiveGift(ctx context.Context, req *pb.ReceiveGiftRequest) (*pb.ReceiveGiftResponse, error) {
|
||||||
|
log.Printf("[GiftService] ReceiveGift: giftUuids=%d", len(req.UserGiftUuid))
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
received := make([]string, 0, len(req.UserGiftUuid))
|
||||||
|
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
remaining := make([]store.NotReceivedGiftState, 0, len(user.Gifts.NotReceived))
|
||||||
|
for _, gift := range user.Gifts.NotReceived {
|
||||||
|
if slices.Contains(req.UserGiftUuid, gift.UserGiftUuid) {
|
||||||
|
received = append(received, gift.UserGiftUuid)
|
||||||
|
user.Gifts.Received = append(user.Gifts.Received, store.ReceivedGiftState{
|
||||||
|
GiftCommon: gift.GiftCommon,
|
||||||
|
ReceivedDatetime: nowMillis,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remaining = append(remaining, gift)
|
||||||
|
}
|
||||||
|
user.Gifts.NotReceived = remaining
|
||||||
|
user.Notifications.GiftNotReceiveCount = int32(len(user.Gifts.NotReceived))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return &pb.ReceiveGiftResponse{
|
||||||
|
ReceivedGiftUuid: []string{},
|
||||||
|
ExpiredGiftUuid: []string{},
|
||||||
|
OverflowGiftUuid: []string{},
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.ReceiveGiftResponse{
|
||||||
|
ReceivedGiftUuid: received,
|
||||||
|
ExpiredGiftUuid: []string{},
|
||||||
|
OverflowGiftUuid: []string{},
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GiftServiceServer) GetGiftList(ctx context.Context, req *pb.GetGiftListRequest) (*pb.GetGiftListResponse, error) {
|
||||||
|
log.Printf("[GiftService] GetGiftList: rewardKinds=%v expirationType=%d ascending=%v nextCursor=%d previousCursor=%d getCount=%d",
|
||||||
|
req.RewardKindType, req.ExpirationType, req.IsAscendingSort, req.NextCursor, req.PreviousCursor, req.GetCount)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, err := s.users.SnapshotUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("snapshot user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gifts := append([]store.NotReceivedGiftState(nil), user.Gifts.NotReceived...)
|
||||||
|
sort.Slice(gifts, func(i, j int) bool {
|
||||||
|
if req.IsAscendingSort {
|
||||||
|
return gifts[i].ExpirationDatetime < gifts[j].ExpirationDatetime
|
||||||
|
}
|
||||||
|
return gifts[i].ExpirationDatetime > gifts[j].ExpirationDatetime
|
||||||
|
})
|
||||||
|
if req.GetCount > 0 && len(gifts) > int(req.GetCount) {
|
||||||
|
gifts = gifts[:req.GetCount]
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]*pb.NotReceivedGift, 0, len(gifts))
|
||||||
|
for _, gift := range gifts {
|
||||||
|
items = append(items, &pb.NotReceivedGift{
|
||||||
|
GiftCommon: toProtoGiftCommon(gift.GiftCommon),
|
||||||
|
ExpirationDatetime: timestampOrNilGift(gift.ExpirationDatetime),
|
||||||
|
UserGiftUuid: gift.UserGiftUuid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.GetGiftListResponse{
|
||||||
|
Gift: items,
|
||||||
|
TotalPageCount: pageCount(len(user.Gifts.NotReceived), int(req.GetCount)),
|
||||||
|
NextCursor: 0,
|
||||||
|
PreviousCursor: 0,
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GiftServiceServer) GetGiftReceiveHistoryList(ctx context.Context, req *emptypb.Empty) (*pb.GetGiftReceiveHistoryListResponse, error) {
|
||||||
|
log.Printf("[GiftService] GetGiftReceiveHistoryList")
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, err := s.users.SnapshotUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("snapshot user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]*pb.ReceivedGift, 0, len(user.Gifts.Received))
|
||||||
|
for _, gift := range user.Gifts.Received {
|
||||||
|
items = append(items, &pb.ReceivedGift{
|
||||||
|
GiftCommon: toProtoGiftCommon(gift.GiftCommon),
|
||||||
|
ReceivedDatetime: timestampOrNilGift(gift.ReceivedDatetime),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &pb.GetGiftReceiveHistoryListResponse{
|
||||||
|
Gift: items,
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProtoGiftCommon(gift store.GiftCommonState) *pb.GiftCommon {
|
||||||
|
return &pb.GiftCommon{
|
||||||
|
PossessionType: gift.PossessionType,
|
||||||
|
PossessionId: gift.PossessionId,
|
||||||
|
Count: gift.Count,
|
||||||
|
GrantDatetime: timestampOrNilGift(gift.GrantDatetime),
|
||||||
|
DescriptionGiftTextId: gift.DescriptionGiftTextId,
|
||||||
|
EquipmentData: gift.EquipmentData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func timestampOrNilGift(unixMillis int64) *timestamppb.Timestamp {
|
||||||
|
if unixMillis == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return timestamppb.New(time.UnixMilli(unixMillis))
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageCount(total, pageSize int) int32 {
|
||||||
|
if total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
pages := total / pageSize
|
||||||
|
if total%pageSize != 0 {
|
||||||
|
pages++
|
||||||
|
}
|
||||||
|
return int32(pages)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
|
||||||
|
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GimmickServiceServer struct {
|
||||||
|
pb.UnimplementedGimmickServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
gimmickCatalog *masterdata.GimmickCatalog
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGimmickServiceServer(users store.UserRepository, sessions store.SessionRepository, gimmickCatalog *masterdata.GimmickCatalog) *GimmickServiceServer {
|
||||||
|
return &GimmickServiceServer{users: users, sessions: sessions, gimmickCatalog: gimmickCatalog}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GimmickServiceServer) UpdateSequence(ctx context.Context, req *pb.UpdateSequenceRequest) (*pb.UpdateSequenceResponse, error) {
|
||||||
|
log.Printf("[GimmickService] UpdateSequence: scheduleId=%d sequenceId=%d",
|
||||||
|
req.GimmickSequenceScheduleId, req.GimmickSequenceId)
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
key := store.GimmickSequenceKey{
|
||||||
|
GimmickSequenceScheduleId: req.GimmickSequenceScheduleId,
|
||||||
|
GimmickSequenceId: req.GimmickSequenceId,
|
||||||
|
}
|
||||||
|
sequence := user.Gimmick.Sequences[key]
|
||||||
|
sequence.Key = key
|
||||||
|
user.Gimmick.Sequences[key] = sequence
|
||||||
|
})
|
||||||
|
return &pb.UpdateSequenceResponse{
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserGimmickSequence"})),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *pb.UpdateGimmickProgressRequest) (*pb.UpdateGimmickProgressResponse, error) {
|
||||||
|
log.Printf("[GimmickService] UpdateGimmickProgress: scheduleId=%d sequenceId=%d gimmickId=%d ornamentIndex=%d progressValueBit=%d flowType=%d",
|
||||||
|
req.GimmickSequenceScheduleId, req.GimmickSequenceId, req.GimmickId, req.GimmickOrnamentIndex, req.ProgressValueBit, req.FlowType)
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
progressKey := store.GimmickKey{
|
||||||
|
GimmickSequenceScheduleId: req.GimmickSequenceScheduleId,
|
||||||
|
GimmickSequenceId: req.GimmickSequenceId,
|
||||||
|
GimmickId: req.GimmickId,
|
||||||
|
}
|
||||||
|
progress := user.Gimmick.Progress[progressKey]
|
||||||
|
progress.Key = progressKey
|
||||||
|
progress.StartDatetime = nowMillis
|
||||||
|
user.Gimmick.Progress[progressKey] = progress
|
||||||
|
|
||||||
|
ornamentKey := store.GimmickOrnamentKey{
|
||||||
|
GimmickSequenceScheduleId: req.GimmickSequenceScheduleId,
|
||||||
|
GimmickSequenceId: req.GimmickSequenceId,
|
||||||
|
GimmickId: req.GimmickId,
|
||||||
|
GimmickOrnamentIndex: req.GimmickOrnamentIndex,
|
||||||
|
}
|
||||||
|
ornament := user.Gimmick.OrnamentProgress[ornamentKey]
|
||||||
|
ornament.Key = ornamentKey
|
||||||
|
ornament.ProgressValueBit = req.ProgressValueBit
|
||||||
|
ornament.BaseDatetime = nowMillis
|
||||||
|
user.Gimmick.OrnamentProgress[ornamentKey] = ornament
|
||||||
|
})
|
||||||
|
return &pb.UpdateGimmickProgressResponse{
|
||||||
|
GimmickOrnamentReward: []*pb.GimmickReward{},
|
||||||
|
IsSequenceCleared: false,
|
||||||
|
GimmickSequenceClearReward: []*pb.GimmickReward{},
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{
|
||||||
|
"IUserGimmick",
|
||||||
|
"IUserGimmickOrnamentProgress",
|
||||||
|
})),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GimmickServiceServer) InitSequenceSchedule(ctx context.Context, _ *emptypb.Empty) (*pb.InitSequenceScheduleResponse, error) {
|
||||||
|
log.Printf("[GimmickService] InitSequenceSchedule")
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
now := gametime.NowMillis()
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
added := 0
|
||||||
|
for _, key := range s.gimmickCatalog.ActiveScheduleKeys(*user, now) {
|
||||||
|
if _, exists := user.Gimmick.Sequences[key]; !exists {
|
||||||
|
user.Gimmick.Sequences[key] = store.GimmickSequenceState{Key: key}
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if added > 0 {
|
||||||
|
log.Printf("[GimmickService] InitSequenceSchedule: added %d sequences (total %d)", added, len(user.Gimmick.Sequences))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return &pb.InitSequenceScheduleResponse{
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), gimmickDiffTables)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GimmickServiceServer) Unlock(ctx context.Context, req *pb.UnlockRequest) (*pb.UnlockResponse, error) {
|
||||||
|
log.Printf("[GimmickService] Unlock: gimmickKeys=%d", len(req.GimmickKey))
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
for _, item := range req.GimmickKey {
|
||||||
|
key := store.GimmickKey{
|
||||||
|
GimmickSequenceScheduleId: item.GimmickSequenceScheduleId,
|
||||||
|
GimmickSequenceId: item.GimmickSequenceId,
|
||||||
|
GimmickId: item.GimmickId,
|
||||||
|
}
|
||||||
|
unlock := user.Gimmick.Unlocks[key]
|
||||||
|
unlock.Key = key
|
||||||
|
unlock.IsUnlocked = true
|
||||||
|
user.Gimmick.Unlocks[key] = unlock
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return &pb.UnlockResponse{
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserGimmickUnlock"})),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listBinEntry holds path (')' as segment separator) and size from list.bin; Size is 0 when not present.
|
||||||
|
type listBinEntry struct {
|
||||||
|
Path string
|
||||||
|
Size int64
|
||||||
|
MD5 string
|
||||||
|
}
|
||||||
|
|
||||||
|
// listBinIndex caches object_id → entry for a revision.
|
||||||
|
type listBinIndex map[string]listBinEntry
|
||||||
|
|
||||||
|
type infoAlias struct {
|
||||||
|
ToName string
|
||||||
|
ToRevision string
|
||||||
|
MD5 string
|
||||||
|
}
|
||||||
|
|
||||||
|
type assetCandidate struct {
|
||||||
|
Path string
|
||||||
|
Revision string
|
||||||
|
Source string
|
||||||
|
ExpectedMD5 string
|
||||||
|
}
|
||||||
|
|
||||||
|
type listBinLoad struct {
|
||||||
|
done chan struct{}
|
||||||
|
idx listBinIndex
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type infoLoad struct {
|
||||||
|
done chan struct{}
|
||||||
|
m map[string]infoAlias
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
listBinCache = make(map[string]listBinIndex) // revision → index
|
||||||
|
listBinInflight = make(map[string]*listBinLoad)
|
||||||
|
listBinCacheMu sync.RWMutex
|
||||||
|
infoCache = make(map[string]map[string]infoAlias) // revision → from-name → duplicate target
|
||||||
|
infoInflight = make(map[string]*infoLoad)
|
||||||
|
infoCacheMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// infoJSONEntry is one entry from assets/revisions/{rev}/info.json (duplicate files: serve to-name when asked for from-name).
|
||||||
|
type infoJSONEntry struct {
|
||||||
|
FromName string `json:"from-name"`
|
||||||
|
ToName string `json:"to-name"`
|
||||||
|
ToRevision *int `json:"to-revision"`
|
||||||
|
MD5 string `json:"md5"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// readVarint reads a protobuf varint from b, returns value and number of bytes consumed.
|
||||||
|
func readVarint(b []byte) (value int, n int) {
|
||||||
|
for i := 0; i < len(b) && i < 10; i++ {
|
||||||
|
value |= int(b[i]&0x7f) << (7 * i)
|
||||||
|
n = i + 1
|
||||||
|
if b[i]&0x80 == 0 {
|
||||||
|
return value, n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipProtoField(wireType int, data []byte, offset int) (int, bool) {
|
||||||
|
switch wireType {
|
||||||
|
case 0:
|
||||||
|
_, n := readVarint(data[offset:])
|
||||||
|
if n == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return offset + n, true
|
||||||
|
case 1:
|
||||||
|
if offset+8 > len(data) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return offset + 8, true
|
||||||
|
case 2:
|
||||||
|
length, n := readVarint(data[offset:])
|
||||||
|
if n == 0 || length < 0 || offset+n+length > len(data) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return offset + n + length, true
|
||||||
|
case 5:
|
||||||
|
if offset+4 > len(data) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return offset + 4, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseListBinEntry(data []byte) (objectId string, entry listBinEntry, ok bool) {
|
||||||
|
i := 0
|
||||||
|
for i < len(data) {
|
||||||
|
tag, n := readVarint(data[i:])
|
||||||
|
if n == 0 {
|
||||||
|
return "", listBinEntry{}, false
|
||||||
|
}
|
||||||
|
i += n
|
||||||
|
fieldNum := tag >> 3
|
||||||
|
wireType := tag & 0x7
|
||||||
|
|
||||||
|
switch fieldNum {
|
||||||
|
case 3: // path
|
||||||
|
if wireType != 2 {
|
||||||
|
return "", listBinEntry{}, false
|
||||||
|
}
|
||||||
|
length, vn := readVarint(data[i:])
|
||||||
|
if vn == 0 || length < 0 || i+vn+length > len(data) {
|
||||||
|
return "", listBinEntry{}, false
|
||||||
|
}
|
||||||
|
entry.Path = string(data[i+vn : i+vn+length])
|
||||||
|
i += vn + length
|
||||||
|
case 4: // size
|
||||||
|
if wireType != 0 {
|
||||||
|
return "", listBinEntry{}, false
|
||||||
|
}
|
||||||
|
size, vn := readVarint(data[i:])
|
||||||
|
if vn == 0 {
|
||||||
|
return "", listBinEntry{}, false
|
||||||
|
}
|
||||||
|
if size >= 256 {
|
||||||
|
entry.Size = int64(size)
|
||||||
|
}
|
||||||
|
i += vn
|
||||||
|
case 10: // md5
|
||||||
|
if wireType != 2 {
|
||||||
|
return "", listBinEntry{}, false
|
||||||
|
}
|
||||||
|
length, vn := readVarint(data[i:])
|
||||||
|
if vn == 0 || length < 0 || i+vn+length > len(data) {
|
||||||
|
return "", listBinEntry{}, false
|
||||||
|
}
|
||||||
|
entry.MD5 = string(data[i+vn : i+vn+length])
|
||||||
|
i += vn + length
|
||||||
|
case 11: // object_id
|
||||||
|
if wireType != 2 {
|
||||||
|
return "", listBinEntry{}, false
|
||||||
|
}
|
||||||
|
length, vn := readVarint(data[i:])
|
||||||
|
if vn == 0 || length <= 0 || i+vn+length > len(data) {
|
||||||
|
return "", listBinEntry{}, false
|
||||||
|
}
|
||||||
|
objectId = string(data[i+vn : i+vn+length])
|
||||||
|
i += vn + length
|
||||||
|
default:
|
||||||
|
next, ok := skipProtoField(wireType, data, i)
|
||||||
|
if !ok {
|
||||||
|
return "", listBinEntry{}, false
|
||||||
|
}
|
||||||
|
i = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if objectId == "" || entry.Path == "" {
|
||||||
|
return "", listBinEntry{}, false
|
||||||
|
}
|
||||||
|
return objectId, entry, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseListBin reads list.bin and builds object_id (6-byte string) → entry (path, size, md5).
|
||||||
|
// The file is a protobuf message with repeated nested entry messages, so we parse each entry
|
||||||
|
// boundary first instead of doing a flat scan across the whole file.
|
||||||
|
func parseListBin(data []byte) listBinIndex {
|
||||||
|
idx := make(listBinIndex)
|
||||||
|
i := 0
|
||||||
|
for i < len(data) {
|
||||||
|
tag, n := readVarint(data[i:])
|
||||||
|
if n == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i += n
|
||||||
|
wireType := tag & 0x7
|
||||||
|
|
||||||
|
if wireType == 2 {
|
||||||
|
length, vn := readVarint(data[i:])
|
||||||
|
if vn == 0 || length < 0 || i+vn+length > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
entryBytes := data[i+vn : i+vn+length]
|
||||||
|
objectId, entry, ok := parseListBinEntry(entryBytes)
|
||||||
|
if ok {
|
||||||
|
idx[objectId] = entry
|
||||||
|
i += vn + length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next, ok := skipProtoField(wireType, data, i)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i = next
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadListBinIndex(revision string) (listBinIndex, bool) {
|
||||||
|
listBinCacheMu.RLock()
|
||||||
|
idx, ok := listBinCache[revision]
|
||||||
|
listBinCacheMu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return idx, true
|
||||||
|
}
|
||||||
|
|
||||||
|
listBinCacheMu.Lock()
|
||||||
|
if idx, ok := listBinCache[revision]; ok {
|
||||||
|
listBinCacheMu.Unlock()
|
||||||
|
return idx, true
|
||||||
|
}
|
||||||
|
if load := listBinInflight[revision]; load != nil {
|
||||||
|
listBinCacheMu.Unlock()
|
||||||
|
<-load.done
|
||||||
|
return load.idx, load.ok
|
||||||
|
}
|
||||||
|
load := &listBinLoad{done: make(chan struct{})}
|
||||||
|
listBinInflight[revision] = load
|
||||||
|
listBinCacheMu.Unlock()
|
||||||
|
|
||||||
|
filePath := filepath.Join("assets", "revisions", revision, "list.bin")
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
listBinCacheMu.Lock()
|
||||||
|
delete(listBinInflight, revision)
|
||||||
|
close(load.done)
|
||||||
|
listBinCacheMu.Unlock()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
idx = parseListBin(data)
|
||||||
|
load.idx = idx
|
||||||
|
load.ok = true
|
||||||
|
listBinCacheMu.Lock()
|
||||||
|
listBinCache[revision] = idx
|
||||||
|
delete(listBinInflight, revision)
|
||||||
|
close(load.done)
|
||||||
|
listBinCacheMu.Unlock()
|
||||||
|
return idx, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadInfoIndex(revision string) map[string]infoAlias {
|
||||||
|
infoCacheMu.RLock()
|
||||||
|
m, ok := infoCache[revision]
|
||||||
|
infoCacheMu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
infoCacheMu.Lock()
|
||||||
|
if m, ok := infoCache[revision]; ok {
|
||||||
|
infoCacheMu.Unlock()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
if load := infoInflight[revision]; load != nil {
|
||||||
|
infoCacheMu.Unlock()
|
||||||
|
<-load.done
|
||||||
|
return load.m
|
||||||
|
}
|
||||||
|
load := &infoLoad{done: make(chan struct{})}
|
||||||
|
infoInflight[revision] = load
|
||||||
|
infoCacheMu.Unlock()
|
||||||
|
|
||||||
|
filePath := filepath.Join("assets", "revisions", revision, "info.json")
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
infoCacheMu.Lock()
|
||||||
|
infoCache[revision] = nil
|
||||||
|
delete(infoInflight, revision)
|
||||||
|
close(load.done)
|
||||||
|
infoCacheMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var entries []infoJSONEntry
|
||||||
|
if err := json.Unmarshal(data, &entries); err != nil {
|
||||||
|
infoCacheMu.Lock()
|
||||||
|
infoCache[revision] = nil
|
||||||
|
delete(infoInflight, revision)
|
||||||
|
close(load.done)
|
||||||
|
infoCacheMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m = make(map[string]infoAlias)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.FromName != "" && e.ToName != "" {
|
||||||
|
aliasRevision := revision
|
||||||
|
if e.ToRevision != nil {
|
||||||
|
aliasRevision = strconv.Itoa(*e.ToRevision)
|
||||||
|
}
|
||||||
|
m[e.FromName] = infoAlias{
|
||||||
|
ToName: e.ToName,
|
||||||
|
ToRevision: aliasRevision,
|
||||||
|
MD5: e.MD5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load.m = m
|
||||||
|
infoCacheMu.Lock()
|
||||||
|
infoCache[revision] = m
|
||||||
|
delete(infoInflight, revision)
|
||||||
|
close(load.done)
|
||||||
|
infoCacheMu.Unlock()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathStrToFullPaths(revision, assetType, pathStr string) []string {
|
||||||
|
fsPath := strings.ReplaceAll(pathStr, ")", "/")
|
||||||
|
if strings.Contains(fsPath, "..") || filepath.IsAbs(fsPath) || strings.HasPrefix(fsPath, "/") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fsPath = filepath.Clean(fsPath)
|
||||||
|
if strings.Contains(fsPath, "..") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Prefer "global" (en) when list.bin points to ja/ko: try en first, then original.
|
||||||
|
var pathStrs []string
|
||||||
|
if strings.Contains(pathStr, ")ja)") {
|
||||||
|
pathStrs = append(pathStrs, strings.ReplaceAll(pathStr, ")ja)", ")en)"))
|
||||||
|
}
|
||||||
|
if strings.Contains(pathStr, ")ko)") {
|
||||||
|
pathStrs = append(pathStrs, strings.ReplaceAll(pathStr, ")ko)", ")en)"))
|
||||||
|
}
|
||||||
|
pathStrs = append(pathStrs, pathStr)
|
||||||
|
base := filepath.Join("assets", "revisions", revision)
|
||||||
|
var out []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, p := range pathStrs {
|
||||||
|
cleaned := filepath.Clean(strings.ReplaceAll(p, ")", "/"))
|
||||||
|
if seen[cleaned] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[cleaned] = true
|
||||||
|
switch assetType {
|
||||||
|
case "assetbundle":
|
||||||
|
out = append(out, filepath.Join(base, "assetbundle", cleaned+".assetbundle"))
|
||||||
|
case "resources":
|
||||||
|
out = append(out, filepath.Join(base, "resources", cleaned))
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUniqueCandidate(candidates []assetCandidate, seen map[string]bool, candidate assetCandidate) []assetCandidate {
|
||||||
|
key := candidate.Revision + ":" + candidate.Path
|
||||||
|
if seen[key] {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
return append(candidates, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateCandidatePath(candidate assetCandidate, assetType, targetRevision, targetBaseName string) string {
|
||||||
|
root := filepath.Join("assets", "revisions", candidate.Revision, assetType)
|
||||||
|
rel, err := filepath.Rel(root, candidate.Path)
|
||||||
|
if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join("assets", "revisions", targetRevision, assetType, filepath.Dir(rel), targetBaseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectIdToFilePathCandidates returns candidate file paths for the object: list.bin path, locale fallbacks
|
||||||
|
// (ja/ko -> en), then info.json duplicate mappings (from-name -> to-name, possibly in a different revision).
|
||||||
|
// Callers should try each path until one exists on disk.
|
||||||
|
func objectIdToFilePathCandidates(revision, assetType, objectId string) (candidates []assetCandidate, size int64, ok bool) {
|
||||||
|
idx, ok := loadListBinIndex(revision)
|
||||||
|
if !ok || idx == nil {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
entry, ok := idx[objectId]
|
||||||
|
if !ok || entry.Path == "" {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
paths := pathStrToFullPaths(revision, assetType, entry.Path)
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, path := range paths {
|
||||||
|
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
|
||||||
|
Path: path,
|
||||||
|
Revision: revision,
|
||||||
|
Source: "list.bin",
|
||||||
|
ExpectedMD5: entry.MD5,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Add paths from info.json: when requested file is a "from-name" (duplicate not included), serve "to-name" instead.
|
||||||
|
infoIndex := loadInfoIndex(revision)
|
||||||
|
if len(infoIndex) > 0 {
|
||||||
|
for _, c := range candidates {
|
||||||
|
alias, ok := infoIndex[filepath.Base(c.Path)]
|
||||||
|
if !ok || alias.ToName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alt := duplicateCandidatePath(c, assetType, alias.ToRevision, alias.ToName)
|
||||||
|
if alt == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
|
||||||
|
Path: alt,
|
||||||
|
Revision: alias.ToRevision,
|
||||||
|
Source: "info.json redirect",
|
||||||
|
ExpectedMD5: alias.MD5,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates, entry.Size, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
|
||||||
|
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoginBonusServiceServer struct {
|
||||||
|
pb.UnimplementedLoginBonusServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.LoginBonusCatalog
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLoginBonusServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.LoginBonusCatalog) *LoginBonusServiceServer {
|
||||||
|
return &LoginBonusServiceServer{users: users, sessions: sessions, catalog: catalog}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LoginBonusServiceServer) ReceiveStamp(ctx context.Context, req *emptypb.Empty) (*pb.ReceiveStampResponse, error) {
|
||||||
|
log.Printf("[LoginBonusService] ReceiveStamp")
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
now := gametime.NowMillis()
|
||||||
|
nextStamp := user.LoginBonus.CurrentStampNumber + 1
|
||||||
|
|
||||||
|
reward, ok := s.catalog.LookupStampReward(
|
||||||
|
user.LoginBonus.LoginBonusId,
|
||||||
|
user.LoginBonus.CurrentPageNumber,
|
||||||
|
nextStamp,
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
log.Fatalf("[LoginBonusService] no reward found for bonusId=%d page=%d stamp=%d",
|
||||||
|
user.LoginBonus.LoginBonusId, user.LoginBonus.CurrentPageNumber, nextStamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[LoginBonusService] stamp %d -> possType=%d possId=%d count=%d (-> gift box)",
|
||||||
|
nextStamp, reward.PossessionType, reward.PossessionId, reward.Count)
|
||||||
|
|
||||||
|
user.Gifts.NotReceived = append(user.Gifts.NotReceived, store.NotReceivedGiftState{
|
||||||
|
GiftCommon: store.GiftCommonState{
|
||||||
|
PossessionType: reward.PossessionType,
|
||||||
|
PossessionId: reward.PossessionId,
|
||||||
|
Count: reward.Count,
|
||||||
|
GrantDatetime: now,
|
||||||
|
},
|
||||||
|
ExpirationDatetime: now + int64(30*24*time.Hour/time.Millisecond),
|
||||||
|
UserGiftUuid: fmt.Sprintf("login-bonus-%d-%d", userId, nextStamp),
|
||||||
|
})
|
||||||
|
user.Notifications.GiftNotReceiveCount = int32(len(user.Gifts.NotReceived))
|
||||||
|
user.LoginBonus.CurrentStampNumber = nextStamp
|
||||||
|
user.LoginBonus.LatestRewardReceiveDatetime = now
|
||||||
|
user.LoginBonus.LatestVersion = now
|
||||||
|
})
|
||||||
|
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(
|
||||||
|
userdata.FullClientTableMap(user),
|
||||||
|
[]string{"IUserLoginBonus"},
|
||||||
|
))
|
||||||
|
setCommonResponseTrailers(ctx, diff, false)
|
||||||
|
return &pb.ReceiveStampResponse{DiffUserData: diff}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
var materialDiffTables = []string{
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
}
|
||||||
|
|
||||||
|
type MaterialServiceServer struct {
|
||||||
|
pb.UnimplementedMaterialServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.MaterialCatalog
|
||||||
|
config *masterdata.GameConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMaterialServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.MaterialCatalog, config *masterdata.GameConfig) *MaterialServiceServer {
|
||||||
|
return &MaterialServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MaterialServiceServer) Sell(ctx context.Context, req *pb.MaterialSellRequest) (*pb.MaterialSellResponse, error) {
|
||||||
|
log.Printf("[MaterialService] Sell: %d item(s)", len(req.MaterialPossession))
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
|
||||||
|
oldUser, _ := s.users.SnapshotUser(userId)
|
||||||
|
tracker := userdata.NewDeleteTracker().
|
||||||
|
Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"})
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
totalGold := int32(0)
|
||||||
|
for _, item := range req.MaterialPossession {
|
||||||
|
mat, ok := s.catalog.All[item.MaterialId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[MaterialService] Sell: unknown materialId=%d, skipping", item.MaterialId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cur := user.Materials[item.MaterialId]
|
||||||
|
if cur < item.Count {
|
||||||
|
log.Printf("[MaterialService] Sell: insufficient materialId=%d have=%d need=%d", item.MaterialId, cur, item.Count)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Materials[item.MaterialId] -= item.Count
|
||||||
|
if user.Materials[item.MaterialId] <= 0 {
|
||||||
|
delete(user.Materials, item.MaterialId)
|
||||||
|
}
|
||||||
|
|
||||||
|
gold := mat.SellPrice * item.Count
|
||||||
|
totalGold += gold
|
||||||
|
log.Printf("[MaterialService] Sell: materialId=%d x%d -> %d gold", item.MaterialId, item.Count, gold)
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalGold > 0 {
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold
|
||||||
|
log.Printf("[MaterialService] Sell: total gold +%d", totalGold)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("material sell: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), materialDiffTables)
|
||||||
|
diff := tracker.Apply(snapshot, tables)
|
||||||
|
|
||||||
|
return &pb.MaterialSellResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MissionServiceServer struct {
|
||||||
|
pb.UnimplementedMissionServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMissionServiceServer(users store.UserRepository, sessions store.SessionRepository) *MissionServiceServer {
|
||||||
|
return &MissionServiceServer{users: users, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MissionServiceServer) UpdateMissionProgress(ctx context.Context, req *pb.UpdateMissionProgressRequest) (*pb.UpdateMissionProgressResponse, error) {
|
||||||
|
log.Printf("[MissionService] UpdateMissionProgress: cage=%v pictureBook=%v", req.CageMeasurableValues, req.PictureBookMeasurableValues)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
snapshot, err := s.users.SnapshotUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("snapshot user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserMission"}))
|
||||||
|
|
||||||
|
return &pb.UpdateMissionProgressResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MovieServiceServer struct {
|
||||||
|
pb.UnimplementedMovieServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMovieServiceServer(users store.UserRepository, sessions store.SessionRepository) *MovieServiceServer {
|
||||||
|
return &MovieServiceServer{users: users, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MovieServiceServer) SaveViewedMovie(ctx context.Context, req *pb.SaveViewedMovieRequest) (*pb.SaveViewedMovieResponse, error) {
|
||||||
|
log.Printf("[MovieService] SaveViewedMovie: movieIds=%v", req.MovieId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
now := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
for _, mid := range req.MovieId {
|
||||||
|
user.ViewedMovies[mid] = now
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserMovie"}))
|
||||||
|
|
||||||
|
return &pb.SaveViewedMovieResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NaviCutInServiceServer struct {
|
||||||
|
pb.UnimplementedNaviCutInServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNaviCutInServiceServer(users store.UserRepository, sessions store.SessionRepository) *NaviCutInServiceServer {
|
||||||
|
return &NaviCutInServiceServer{users: users, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NaviCutInServiceServer) RegisterPlayed(ctx context.Context, req *pb.RegisterPlayedRequest) (*pb.RegisterPlayedResponse, error) {
|
||||||
|
log.Printf("[NaviCutInService] RegisterPlayed: naviCutId=%d", req.NaviCutId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.NaviCutInPlayed[req.NaviCutId] = true
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserNaviCutIn"}))
|
||||||
|
|
||||||
|
return &pb.RegisterPlayedResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
|
||||||
|
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotificationServiceServer struct {
|
||||||
|
pb.UnimplementedNotificationServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNotificationServiceServer(users store.UserRepository, sessions store.SessionRepository) *NotificationServiceServer {
|
||||||
|
return &NotificationServiceServer{users: users, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NotificationServiceServer) GetHeaderNotification(ctx context.Context, req *emptypb.Empty) (*pb.GetHeaderNotificationResponse, error) {
|
||||||
|
log.Printf("[NotificationService] GetHeaderNotification")
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, err := s.users.SnapshotUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return &pb.GetHeaderNotificationResponse{
|
||||||
|
GiftNotReceiveCount: 0,
|
||||||
|
FriendRequestReceiveCount: 0,
|
||||||
|
IsExistUnreadInformation: false,
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &pb.GetHeaderNotificationResponse{
|
||||||
|
GiftNotReceiveCount: int32(len(user.Gifts.NotReceived)),
|
||||||
|
FriendRequestReceiveCount: user.Notifications.FriendRequestReceiveCount,
|
||||||
|
IsExistUnreadInformation: user.Notifications.IsExistUnreadInformation,
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const termsVersionMarker = "###123###"
|
||||||
|
const privacyVersionMarker = "###123###"
|
||||||
|
|
||||||
|
const informationPage = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Lunar Tear</title>
|
||||||
|
<style>
|
||||||
|
body { margin:0; padding:40px 20px; font-family:"Noto Sans",sans-serif;
|
||||||
|
background:#0a0a0f; color:#d4cfc6; text-align:center; }
|
||||||
|
h1 { font-size:1.4em; letter-spacing:.15em; color:#e8e0d0; margin-bottom:4px; }
|
||||||
|
.sub { font-size:.75em; color:#888; margin-bottom:32px; }
|
||||||
|
.sep { width:60px; border:none; border-top:1px solid #333; margin:24px auto; }
|
||||||
|
p { font-size:.85em; line-height:1.6; color:#999; max-width:360px; margin:0 auto 12px; }
|
||||||
|
a { color:#a0c4e8; text-decoration:none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>LUNAR TEAR</h1>
|
||||||
|
<div class="sub">Private Preservation Server</div>
|
||||||
|
<hr class="sep">
|
||||||
|
<p>A community effort to keep NieR Re[in]carnation playable after official service ended.</p>
|
||||||
|
<p>This server is not affiliated with or endorsed by SQUARE ENIX or Applibot.</p>
|
||||||
|
<hr class="sep">
|
||||||
|
<p style="font-size:.7em;color:#666;">© SQUARE ENIX / Applibot — All game assets belong to their respective owners.</p>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
// resourcesURLOriginal is the base URL embedded in list.bin; must be replaced with same-length (43 bytes) when rewriting.
|
||||||
|
const resourcesURLOriginal = "https://resources.app.nierreincarnation.com"
|
||||||
|
|
||||||
|
type OctoHTTPServer struct {
|
||||||
|
mux *http.ServeMux
|
||||||
|
ResourcesBaseURL string // if non-empty and exactly 43 chars, list.bin is rewritten to use this base for asset URLs
|
||||||
|
revisions *revisionTracker
|
||||||
|
resolver *assetResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
func staticPageLanguage(path string) string {
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
for i := 0; i+1 < len(parts); i++ {
|
||||||
|
if parts[i] == "static" && parts[i+1] != "" {
|
||||||
|
return parts[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderStaticTermsPage(title, language, version string) string {
|
||||||
|
return "<html><head><title>" + title + "</title></head><body><h1>" + title +
|
||||||
|
"</h1><p>Language: " + language + "</p><p>Version: " + version + "</p></body></html>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// countResponseWriter wraps http.ResponseWriter and counts bytes written.
|
||||||
|
type countResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
n int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileMD5Entry struct {
|
||||||
|
size int64
|
||||||
|
modTime int64
|
||||||
|
md5 string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
fileMD5Cache = make(map[string]fileMD5Entry)
|
||||||
|
fileMD5CacheMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *countResponseWriter) Write(p []byte) (int, error) {
|
||||||
|
n, err := c.ResponseWriter.Write(p)
|
||||||
|
c.n += int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileMD5Hex(path string, info os.FileInfo) (string, error) {
|
||||||
|
modTime := info.ModTime().UnixNano()
|
||||||
|
|
||||||
|
fileMD5CacheMu.RLock()
|
||||||
|
cached, ok := fileMD5Cache[path]
|
||||||
|
fileMD5CacheMu.RUnlock()
|
||||||
|
if ok && cached.size == info.Size() && cached.modTime == modTime {
|
||||||
|
return cached.md5, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
h := md5.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := hex.EncodeToString(h.Sum(nil))
|
||||||
|
|
||||||
|
fileMD5CacheMu.Lock()
|
||||||
|
fileMD5Cache[path] = fileMD5Entry{
|
||||||
|
size: info.Size(),
|
||||||
|
modTime: modTime,
|
||||||
|
md5: sum,
|
||||||
|
}
|
||||||
|
fileMD5CacheMu.Unlock()
|
||||||
|
return sum, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOctoHTTPServer(resourcesBaseURL string) *OctoHTTPServer {
|
||||||
|
s := &OctoHTTPServer{
|
||||||
|
mux: http.NewServeMux(),
|
||||||
|
ResourcesBaseURL: resourcesBaseURL,
|
||||||
|
revisions: newRevisionTracker(),
|
||||||
|
resolver: newAssetResolver(),
|
||||||
|
}
|
||||||
|
s.mux.HandleFunc("/", s.handleAll)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OctoHTTPServer) Handler() http.Handler {
|
||||||
|
return s.mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
isAssetRequest := strings.Contains(path, "/unso-")
|
||||||
|
isMasterDataRequest := strings.Contains(path, "/assets/release/") && strings.Contains(path, "database.bin")
|
||||||
|
if !isAssetRequest && !isMasterDataRequest {
|
||||||
|
log.Printf("[HTTP] %s %s (Host: %s)", r.Method, r.URL.String(), r.Host)
|
||||||
|
for k, v := range r.Header {
|
||||||
|
log.Printf("[HTTP] %s: %s", k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Octo v2 API — asset bundle management
|
||||||
|
if strings.HasPrefix(path, "/v2/") {
|
||||||
|
s.handleOctoV2(w, r, path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Octo v1 list: /v1/list/{version}/{revision} — same list.bin as v2, keyed by revision
|
||||||
|
if strings.HasPrefix(path, "/v1/list/") {
|
||||||
|
s.serveOctoV1List(w, r, path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game web API requests
|
||||||
|
if strings.Contains(path, "/web/") || strings.Contains(r.Host, "web.app.nierreincarnation") {
|
||||||
|
s.handleWebAPI(w, r, path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Master data download (should not be reached if version matches)
|
||||||
|
if strings.HasPrefix(path, "/master-data/") {
|
||||||
|
log.Printf("[HTTP] Master data request for path: %s — returning empty", path)
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Header().Set("Content-Length", "0")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// /assets/release/{version}/database.bin.e — master data (HEAD/GET), same as MariesWonderland
|
||||||
|
if strings.Contains(path, "/assets/release/") && strings.Contains(path, "database.bin") {
|
||||||
|
s.serveDatabaseBinE(w, r, path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset bundle requests (from list.bin URLs: .../unso-{v}-{type}/{o}?generation=...&alt=media)
|
||||||
|
if strings.Contains(path, "/unso-") {
|
||||||
|
s.serveUnsoAsset(w, r, path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-game information / news page
|
||||||
|
if strings.Contains(path, "/information") {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(informationPage))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log request body for debugging Octo protocol
|
||||||
|
if r.Body != nil {
|
||||||
|
body := make([]byte, 4096)
|
||||||
|
n, _ := r.Body.Read(body)
|
||||||
|
if n > 0 {
|
||||||
|
log.Printf("[HTTP] body (%d bytes): %x", n, body[:n])
|
||||||
|
if n < 256 {
|
||||||
|
log.Printf("[HTTP] body (ascii): %s", string(body[:n]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[HTTP] >>> UNHANDLED REQUEST: %s %s — returning empty 200", r.Method, path)
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
|
log.Printf("[OctoV2] %s %s", r.Method, path)
|
||||||
|
|
||||||
|
// /v2/pub/a/{appId}/v/{version}/list/{offset} — resource listing
|
||||||
|
if strings.Contains(path, "/list/") {
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
requestedRevision := parts[len(parts)-1]
|
||||||
|
if requestedRevision != "" {
|
||||||
|
revision := "0"
|
||||||
|
filePath := "assets/revisions/0/list.bin"
|
||||||
|
if requestedRevision != revision {
|
||||||
|
log.Printf("[OctoV2] Resource list request revision=%s canonicalized to revision=%s", requestedRevision, revision)
|
||||||
|
}
|
||||||
|
log.Printf("[OctoV2] Resource list request — serving %s (requested_revision=%s canonical_revision=%s)", filePath, requestedRevision, revision)
|
||||||
|
s.revisions.Remember(r.RemoteAddr, revision)
|
||||||
|
go s.resolver.Prewarm(revision)
|
||||||
|
s.serveListBin(w, filePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[OctoV2] Resource list request without revision segment — returning empty protobuf")
|
||||||
|
w.Header().Set("Content-Type", "application/x-protobuf")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// /v2/pub/a/{appId}/v/{version}/info — DB info
|
||||||
|
if strings.Contains(path, "/info") {
|
||||||
|
log.Printf("[OctoV2] Info request — returning empty protobuf")
|
||||||
|
w.Header().Set("Content-Type", "application/x-protobuf")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[OctoV2] Unknown endpoint: %s — returning empty protobuf", path)
|
||||||
|
w.Header().Set("Content-Type", "application/x-protobuf")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveOctoV1List handles GET /v1/list/{version}/{revision} — serves assets/revisions/{revision}/list.bin.
|
||||||
|
func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
// ["v1", "list", "300116832", "0"] -> revision = last segment
|
||||||
|
requestedRevision := "0"
|
||||||
|
if len(parts) >= 4 {
|
||||||
|
requestedRevision = parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
revision := "0"
|
||||||
|
filePath := "assets/revisions/0/list.bin"
|
||||||
|
if requestedRevision != revision {
|
||||||
|
log.Printf("[OctoV1] list request revision=%s canonicalized to revision=%s", requestedRevision, revision)
|
||||||
|
}
|
||||||
|
log.Printf("[OctoV1] %s %s — serving %s (requested_revision=%s canonical_revision=%s)", r.Method, path, filePath, requestedRevision, revision)
|
||||||
|
s.revisions.Remember(r.RemoteAddr, revision)
|
||||||
|
go s.resolver.Prewarm(revision)
|
||||||
|
s.serveListBin(w, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveUnsoAsset serves asset bundle or resource for URLs like /resource-bundle-server/unso-{version}-{type}/{object_id}.
|
||||||
|
func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
var segment, objectId string
|
||||||
|
for i, p := range parts {
|
||||||
|
if strings.HasPrefix(p, "unso-") && i+1 < len(parts) {
|
||||||
|
segment = p
|
||||||
|
objectId = parts[i+1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if segment == "" || objectId == "" {
|
||||||
|
log.Printf("[HTTP] Asset request malformed: %s", path)
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// segment = "unso-200116832-assetbundle" -> type = last part after "-"
|
||||||
|
segParts := strings.Split(segment, "-")
|
||||||
|
if len(segParts) < 2 {
|
||||||
|
log.Printf("[HTTP] Asset request segment malformed: %s", segment)
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assetType := segParts[len(segParts)-1] // "assetbundle" or "resources"
|
||||||
|
if assetType != "assetbundle" && assetType != "resources" {
|
||||||
|
log.Printf("[HTTP] Asset request unknown type: %s", assetType)
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeRevision := s.revisions.Active(r.RemoteAddr)
|
||||||
|
resolution, ok := s.resolver.Resolve(objectId, assetType, activeRevision)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s) no candidates", path, objectId, assetType, activeRevision)
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
baseDir := filepath.Join("assets", "revisions")
|
||||||
|
var triedPaths []string
|
||||||
|
var md5Mismatches []string
|
||||||
|
for _, candidate := range resolution.Candidates {
|
||||||
|
rel, err := filepath.Rel(baseDir, candidate.Path)
|
||||||
|
if err != nil || strings.Contains(rel, "..") || filepath.IsAbs(rel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
triedPaths = append(triedPaths, candidate.Revision+":"+candidate.Path+" ["+candidate.Source+"]")
|
||||||
|
f, err := os.Open(candidate.Path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
f.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
f.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Only validate size when list.bin gave a plausible file size (>= 256); small values are often wrong (e.g. different proto field).
|
||||||
|
if resolution.ListSize >= 256 && info.Size() != resolution.ListSize {
|
||||||
|
f.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if candidate.ExpectedMD5 != "" {
|
||||||
|
actualMD5, err := fileMD5Hex(candidate.Path, info)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[HTTP] Asset md5 read failed: %s err=%v", candidate.Path, err)
|
||||||
|
f.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(actualMD5, candidate.ExpectedMD5) {
|
||||||
|
md5Mismatches = append(md5Mismatches, candidate.Revision+":"+candidate.Path+" ["+candidate.Source+"] expected="+candidate.ExpectedMD5+" actual="+actualMD5)
|
||||||
|
log.Printf("[HTTP] Asset md5 mismatch: object_id=%s type=%s path=%s expected=%s actual=%s active_revision=%s list_revision=%s resolved_revision=%s source=%s", objectId, assetType, candidate.Path, candidate.ExpectedMD5, actualMD5, resolution.ActiveRevision, resolution.ListRevision, candidate.Revision, candidate.Source)
|
||||||
|
f.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
cw := &countResponseWriter{ResponseWriter: w}
|
||||||
|
http.ServeContent(cw, r, filepath.Base(candidate.Path), info.ModTime(), f)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(md5Mismatches) > 0 {
|
||||||
|
log.Printf("[HTTP] Asset md5 mismatches: object_id=%s type=%s active_revision=%s list_revision=%s mismatches=%v", objectId, assetType, resolution.ActiveRevision, resolution.ListRevision, md5Mismatches)
|
||||||
|
}
|
||||||
|
log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s list_revision=%s) tried paths: %v", path, objectId, assetType, resolution.ActiveRevision, resolution.ListRevision, triedPaths)
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveListBin reads list.bin from filePath, optionally rewrites the resource base URL to s.ResourcesBaseURL
|
||||||
|
// (must be exactly 43 bytes to preserve protobuf layout), and writes the result to w.
|
||||||
|
func (s *OctoHTTPServer) serveListBin(w http.ResponseWriter, filePath string) {
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Octo] list.bin read error: %v", err)
|
||||||
|
http.Error(w, "list not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orig := []byte(resourcesURLOriginal)
|
||||||
|
if s.ResourcesBaseURL != "" {
|
||||||
|
if len(s.ResourcesBaseURL) != len(orig) {
|
||||||
|
log.Printf("[Octo] resources-base-url length is %d, need %d — serving list.bin unchanged", len(s.ResourcesBaseURL), len(orig))
|
||||||
|
} else {
|
||||||
|
repl := []byte(s.ResourcesBaseURL)
|
||||||
|
if idx := bytes.Index(data, orig); idx >= 0 {
|
||||||
|
copy(data[idx:], repl)
|
||||||
|
log.Printf("[Octo] list.bin: rewrote resource base URL to %s", s.ResourcesBaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/x-protobuf")
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveDatabaseBinE serves MasterMemory database: /assets/release/{version}/database.bin.e
|
||||||
|
// -> assets/release/{version}.bin.e (or assets/release/database.bin.e fallback).
|
||||||
|
func (s *OctoHTTPServer) serveDatabaseBinE(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
var version string
|
||||||
|
for i, p := range parts {
|
||||||
|
if p == "release" && i+1 < len(parts) {
|
||||||
|
version = parts[i+1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filePath := "assets/release/database.bin.e"
|
||||||
|
if version != "" {
|
||||||
|
vPath := "assets/release/" + version + ".bin.e"
|
||||||
|
if _, err := os.Stat(vPath); err == nil {
|
||||||
|
filePath = vPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
http.ServeFile(w, r, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OctoHTTPServer) handleWebAPI(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
|
log.Printf("[WebAPI] Serving: %s", path)
|
||||||
|
|
||||||
|
if strings.Contains(path, "database.bin") {
|
||||||
|
s.serveDatabaseBinE(w, r, path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(path, "termsofuse") {
|
||||||
|
language := staticPageLanguage(path)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(renderStaticTermsPage("Terms of Service", language, termsVersionMarker)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(path, "privacy") {
|
||||||
|
language := staticPageLanguage(path)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(renderStaticTermsPage("Privacy Policy", language, privacyVersionMarker)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(path, "maintenance") {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`<!DOCTYPE html><html><body></body></html>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`<!DOCTYPE html><html><body></body></html>`))
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OmikujiServiceServer struct {
|
||||||
|
pb.UnimplementedOmikujiServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.OmikujiCatalog
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOmikujiServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.OmikujiCatalog) *OmikujiServiceServer {
|
||||||
|
return &OmikujiServiceServer{users: users, sessions: sessions, catalog: catalog}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OmikujiServiceServer) OmikujiDraw(ctx context.Context, req *pb.OmikujiDrawRequest) (*pb.OmikujiDrawResponse, error) {
|
||||||
|
log.Printf("[OmikujiService] OmikujiDraw: omikujiId=%d", req.OmikujiId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
now := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.DrawnOmikuji[req.OmikujiId] = now
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserOmikuji"}))
|
||||||
|
|
||||||
|
return &pb.OmikujiDrawResponse{
|
||||||
|
OmikujiResultAssetId: s.catalog.LookupAssetId(req.OmikujiId),
|
||||||
|
OmikujiItem: []*pb.OmikujiItem{},
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const partsMaxLevel = int32(15)
|
||||||
|
|
||||||
|
var partsDiffTables = []string{
|
||||||
|
"IUserParts",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
}
|
||||||
|
|
||||||
|
type PartsServiceServer struct {
|
||||||
|
pb.UnimplementedPartsServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.PartsCatalog
|
||||||
|
config *masterdata.GameConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPartsServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.PartsCatalog, config *masterdata.GameConfig) *PartsServiceServer {
|
||||||
|
return &PartsServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest) (*pb.PartsSellResponse, error) {
|
||||||
|
log.Printf("[PartsService] Sell: %d part(s)", len(req.UserPartsUuid))
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
|
||||||
|
oldUser, _ := s.users.SnapshotUser(userId)
|
||||||
|
tracker := userdata.NewDeleteTracker().
|
||||||
|
Track("IUserParts", oldUser, userdata.SortedPartsRecords, []string{"userId", "userPartsUuid"})
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
totalGold := int32(0)
|
||||||
|
for _, uuid := range req.UserPartsUuid {
|
||||||
|
part, ok := user.Parts[uuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[PartsService] Sell: uuid=%s not found, skipping", uuid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if part.IsProtected {
|
||||||
|
log.Printf("[PartsService] Sell: uuid=%s is protected, skipping", uuid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
partDef, ok := s.catalog.PartsById[part.PartsId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[PartsService] Sell: partsId=%d not in catalog, skipping", part.PartsId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sellFunc, ok := s.catalog.SellPriceByRarity[partDef.RarityType]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[PartsService] Sell: no sell price func for rarity=%d, skipping", partDef.RarityType)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
gold := sellFunc.Evaluate(part.Level)
|
||||||
|
totalGold += gold
|
||||||
|
delete(user.Parts, uuid)
|
||||||
|
log.Printf("[PartsService] Sell: uuid=%s partsId=%d level=%d -> %d gold", uuid, part.PartsId, part.Level, gold)
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalGold > 0 {
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold
|
||||||
|
log.Printf("[PartsService] Sell: total gold +%d", totalGold)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parts sell: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), partsDiffTables)
|
||||||
|
diff := tracker.Apply(snapshot, tables)
|
||||||
|
|
||||||
|
return &pb.PartsSellResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRequest) (*pb.PartsEnhanceResponse, error) {
|
||||||
|
log.Printf("[PartsService] Enhance: uuid=%s", req.UserPartsUuid)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
isSuccess := false
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
part, ok := user.Parts[req.UserPartsUuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[PartsService] Enhance: part uuid=%s not found", req.UserPartsUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if part.Level >= partsMaxLevel {
|
||||||
|
log.Printf("[PartsService] Enhance: part uuid=%s already at max level %d", req.UserPartsUuid, part.Level)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
partDef, ok := s.catalog.PartsById[part.PartsId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[PartsService] Enhance: part master id=%d not found", part.PartsId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rarity, ok := s.catalog.RarityByRarityType[partDef.RarityType]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[PartsService] Enhance: rarity type=%d not found", partDef.RarityType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
goldCost := int32(0)
|
||||||
|
if prices, ok := s.catalog.PriceByGroupAndLevel[rarity.PartsLevelUpPriceGroupId]; ok {
|
||||||
|
goldCost = prices[part.Level]
|
||||||
|
}
|
||||||
|
|
||||||
|
currentGold := user.ConsumableItems[s.config.ConsumableItemIdForGold]
|
||||||
|
if currentGold < goldCost {
|
||||||
|
log.Printf("[PartsService] Enhance: insufficient gold have=%d need=%d", currentGold, goldCost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
|
||||||
|
|
||||||
|
successRate := int32(1000)
|
||||||
|
if rates, ok := s.catalog.RateByGroupAndLevel[rarity.PartsLevelUpRateGroupId]; ok {
|
||||||
|
if r, ok := rates[part.Level]; ok {
|
||||||
|
successRate = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rand.Intn(1000) < int(successRate) {
|
||||||
|
part.Level++
|
||||||
|
isSuccess = true
|
||||||
|
log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰, cost=%d gold)",
|
||||||
|
part.PartsId, part.Level-1, part.Level, successRate, goldCost)
|
||||||
|
} else {
|
||||||
|
log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰, cost=%d gold)",
|
||||||
|
part.PartsId, part.Level, successRate, goldCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
part.LatestVersion = nowMillis
|
||||||
|
user.Parts[req.UserPartsUuid] = part
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parts enhance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, partsDiffTables))
|
||||||
|
|
||||||
|
return &pb.PartsEnhanceResponse{
|
||||||
|
IsSuccess: isSuccess,
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PartsServiceServer) ReplacePreset(ctx context.Context, req *pb.PartsReplacePresetRequest) (*pb.PartsReplacePresetResponse, error) {
|
||||||
|
log.Printf("[PartsService] ReplacePreset: preset=%d uuids=[%s, %s, %s]",
|
||||||
|
req.UserPartsPresetNumber, req.UserPartsUuid01, req.UserPartsUuid02, req.UserPartsUuid03)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
preset := user.PartsPresets[req.UserPartsPresetNumber]
|
||||||
|
preset.UserPartsPresetNumber = req.UserPartsPresetNumber
|
||||||
|
preset.UserPartsUuid01 = req.UserPartsUuid01
|
||||||
|
preset.UserPartsUuid02 = req.UserPartsUuid02
|
||||||
|
preset.UserPartsUuid03 = req.UserPartsUuid03
|
||||||
|
preset.LatestVersion = nowMillis
|
||||||
|
user.PartsPresets[req.UserPartsPresetNumber] = preset
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parts replace preset: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserPartsPreset"}))
|
||||||
|
|
||||||
|
return &pb.PartsReplacePresetResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PortalCageServiceServer struct {
|
||||||
|
pb.UnimplementedPortalCageServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPortalCageServiceServer(users store.UserRepository, sessions store.SessionRepository) *PortalCageServiceServer {
|
||||||
|
return &PortalCageServiceServer{users: users, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PortalCageServiceServer) UpdatePortalCageSceneProgress(ctx context.Context, req *pb.UpdatePortalCageSceneProgressRequest) (*pb.UpdatePortalCageSceneProgressResponse, error) {
|
||||||
|
log.Printf("[PortalCageService] UpdatePortalCageSceneProgress: portalCageSceneId=%d", req.PortalCageSceneId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
now := gametime.NowMillis()
|
||||||
|
user.PortalCageStatus.IsCurrentProgress = true
|
||||||
|
user.PortalCageStatus.LatestVersion = now
|
||||||
|
})
|
||||||
|
|
||||||
|
tables := userdata.SelectTables(
|
||||||
|
userdata.FullClientTableMap(user),
|
||||||
|
[]string{"IUserPortalCageStatus"},
|
||||||
|
)
|
||||||
|
return &pb.UpdatePortalCageSceneProgressResponse{
|
||||||
|
DiffUserData: userdata.BuildDiffFromTablesOrdered(tables, []string{"IUserPortalCageStatus"}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/questflow"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
|
||||||
|
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BigHuntServiceServer struct {
|
||||||
|
pb.UnimplementedBigHuntServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.BigHuntCatalog
|
||||||
|
engine *questflow.QuestHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBigHuntServiceServer(
|
||||||
|
users store.UserRepository,
|
||||||
|
sessions store.SessionRepository,
|
||||||
|
catalog *masterdata.BigHuntCatalog,
|
||||||
|
engine *questflow.QuestHandler,
|
||||||
|
) *BigHuntServiceServer {
|
||||||
|
return &BigHuntServiceServer{users: users, sessions: sessions, catalog: catalog, engine: engine}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bigHuntDiffTables = []string{
|
||||||
|
"IUserBigHuntProgressStatus",
|
||||||
|
"IUserBigHuntMaxScore",
|
||||||
|
"IUserBigHuntStatus",
|
||||||
|
"IUserBigHuntScheduleMaxScore",
|
||||||
|
"IUserBigHuntWeeklyMaxScore",
|
||||||
|
"IUserBigHuntWeeklyStatus",
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildBigHuntDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData {
|
||||||
|
tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames)
|
||||||
|
return userdata.BuildDiffFromTablesOrdered(tables, tableNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BigHuntServiceServer) StartBigHuntQuest(ctx context.Context, req *pb.StartBigHuntQuestRequest) (*pb.StartBigHuntQuestResponse, error) {
|
||||||
|
log.Printf("[BigHuntService] StartBigHuntQuest: bossQuestId=%d questId=%d deckNumber=%d isDryRun=%v",
|
||||||
|
req.BigHuntBossQuestId, req.BigHuntQuestId, req.UserDeckNumber, req.IsDryRun)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
bhQuest, ok := s.catalog.QuestById[req.BigHuntQuestId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[BigHuntService] StartBigHuntQuest: unknown bigHuntQuestId=%d", req.BigHuntQuestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
if ok {
|
||||||
|
s.engine.HandleBigHuntQuestStart(user, bhQuest.QuestId, req.UserDeckNumber, nowMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
user.BigHuntProgress = store.BigHuntProgress{
|
||||||
|
CurrentBigHuntBossQuestId: req.BigHuntBossQuestId,
|
||||||
|
CurrentBigHuntQuestId: req.BigHuntQuestId,
|
||||||
|
CurrentQuestSceneId: 0,
|
||||||
|
IsDryRun: req.IsDryRun,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
|
||||||
|
user.BigHuntDeckNumber = req.UserDeckNumber
|
||||||
|
|
||||||
|
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
|
||||||
|
st.DailyChallengeCount++
|
||||||
|
st.LatestChallengeDatetime = nowMillis
|
||||||
|
st.LatestVersion = nowMillis
|
||||||
|
user.BigHuntStatuses[req.BigHuntBossQuestId] = st
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.StartBigHuntQuestResponse{
|
||||||
|
DiffUserData: buildBigHuntDiff(user, append([]string{"IUserQuest"}, bigHuntDiffTables...)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BigHuntServiceServer) UpdateBigHuntQuestSceneProgress(ctx context.Context, req *pb.UpdateBigHuntQuestSceneProgressRequest) (*pb.UpdateBigHuntQuestSceneProgressResponse, error) {
|
||||||
|
log.Printf("[BigHuntService] UpdateBigHuntQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.BigHuntProgress.CurrentQuestSceneId = req.QuestSceneId
|
||||||
|
user.BigHuntProgress.LatestVersion = nowMillis
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.UpdateBigHuntQuestSceneProgressResponse{
|
||||||
|
DiffUserData: buildBigHuntDiff(user, []string{"IUserBigHuntProgressStatus"}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.FinishBigHuntQuestRequest) (*pb.FinishBigHuntQuestResponse, error) {
|
||||||
|
log.Printf("[BigHuntService] FinishBigHuntQuest: bossQuestId=%d questId=%d isRetired=%v",
|
||||||
|
req.BigHuntBossQuestId, req.BigHuntQuestId, req.IsRetired)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
bhQuest := s.catalog.QuestById[req.BigHuntQuestId]
|
||||||
|
bossQuest := s.catalog.BossQuestById[req.BigHuntBossQuestId]
|
||||||
|
boss := s.catalog.BossByBossId[bossQuest.BigHuntBossId]
|
||||||
|
|
||||||
|
var scoreInfo *pb.BigHuntScoreInfo
|
||||||
|
var scoreRewards []*pb.BigHuntReward
|
||||||
|
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
s.engine.HandleBigHuntQuestFinish(user, bhQuest.QuestId, req.IsRetired, false, nowMillis)
|
||||||
|
|
||||||
|
if req.IsRetired || user.BigHuntProgress.IsDryRun {
|
||||||
|
user.BigHuntProgress = store.BigHuntProgress{LatestVersion: nowMillis}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := user.BigHuntBattleDetail
|
||||||
|
totalDamage := detail.TotalDamage
|
||||||
|
baseScore := totalDamage
|
||||||
|
|
||||||
|
difficultyBonusPermil := int32(0)
|
||||||
|
if coeff, ok := s.catalog.ScoreCoefficients[bhQuest.BigHuntQuestScoreCoefficientId]; ok {
|
||||||
|
difficultyBonusPermil = coeff
|
||||||
|
}
|
||||||
|
|
||||||
|
aliveBonusPermil := int32(500)
|
||||||
|
|
||||||
|
maxComboBonusPermil := int32(0)
|
||||||
|
if detail.MaxComboCount >= 100 {
|
||||||
|
maxComboBonusPermil = 300
|
||||||
|
} else if detail.MaxComboCount >= 50 {
|
||||||
|
maxComboBonusPermil = 200
|
||||||
|
} else if detail.MaxComboCount >= 20 {
|
||||||
|
maxComboBonusPermil = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
userScore := baseScore * int64(1000+difficultyBonusPermil+aliveBonusPermil+maxComboBonusPermil) / 1000
|
||||||
|
|
||||||
|
isHighScore := false
|
||||||
|
oldMaxBoss := user.BigHuntMaxScores[bossQuest.BigHuntBossId]
|
||||||
|
oldMax := oldMaxBoss.MaxScore
|
||||||
|
if userScore > oldMax {
|
||||||
|
isHighScore = true
|
||||||
|
user.BigHuntMaxScores[bossQuest.BigHuntBossId] = store.BigHuntMaxScore{
|
||||||
|
MaxScore: userScore,
|
||||||
|
MaxScoreUpdateDatetime: nowMillis,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schedKey := store.BigHuntScheduleScoreKey{
|
||||||
|
BigHuntScheduleId: s.catalog.ActiveScheduleId,
|
||||||
|
BigHuntBossId: bossQuest.BigHuntBossId,
|
||||||
|
}
|
||||||
|
oldSchedMax := user.BigHuntScheduleMaxScores[schedKey].MaxScore
|
||||||
|
if userScore > oldSchedMax {
|
||||||
|
user.BigHuntScheduleMaxScores[schedKey] = store.BigHuntScheduleMaxScore{
|
||||||
|
MaxScore: userScore,
|
||||||
|
MaxScoreUpdateDatetime: nowMillis,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
weeklyVersion := gametime.WeeklyVersion(nowMillis)
|
||||||
|
weekKey := store.BigHuntWeeklyScoreKey{
|
||||||
|
BigHuntWeeklyVersion: weeklyVersion,
|
||||||
|
AttributeType: boss.AttributeType,
|
||||||
|
}
|
||||||
|
oldWeeklyMax := user.BigHuntWeeklyMaxScores[weekKey].MaxScore
|
||||||
|
if userScore > oldWeeklyMax {
|
||||||
|
user.BigHuntWeeklyMaxScores[weekKey] = store.BigHuntWeeklyMaxScore{
|
||||||
|
MaxScore: userScore,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assetGradeIconId := s.catalog.ResolveGradeIconId(bossQuest.BigHuntBossId, userScore)
|
||||||
|
|
||||||
|
scoreInfo = &pb.BigHuntScoreInfo{
|
||||||
|
UserScore: userScore,
|
||||||
|
IsHighScore: isHighScore,
|
||||||
|
TotalDamage: totalDamage,
|
||||||
|
BaseScore: baseScore,
|
||||||
|
DifficultyBonusPermil: difficultyBonusPermil,
|
||||||
|
AliveBonusPermil: aliveBonusPermil,
|
||||||
|
MaxComboBonusPermil: maxComboBonusPermil,
|
||||||
|
AssetGradeIconId: assetGradeIconId,
|
||||||
|
}
|
||||||
|
|
||||||
|
if isHighScore {
|
||||||
|
rewardGroupId := s.catalog.ResolveActiveScoreRewardGroupId(
|
||||||
|
bossQuest.BigHuntScoreRewardGroupScheduleId, nowMillis)
|
||||||
|
if rewardGroupId > 0 {
|
||||||
|
newItems := s.catalog.CollectNewRewards(rewardGroupId, oldMax, userScore)
|
||||||
|
for _, item := range newItems {
|
||||||
|
s.engine.Granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis)
|
||||||
|
scoreRewards = append(scoreRewards, &pb.BigHuntReward{
|
||||||
|
PossessionType: item.PossessionType,
|
||||||
|
PossessionId: item.PossessionId,
|
||||||
|
Count: item.Count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.BigHuntProgress = store.BigHuntProgress{LatestVersion: nowMillis}
|
||||||
|
user.BigHuntBattleBinary = nil
|
||||||
|
user.BigHuntBattleDetail = store.BigHuntBattleDetail{}
|
||||||
|
})
|
||||||
|
|
||||||
|
if scoreInfo == nil {
|
||||||
|
scoreInfo = &pb.BigHuntScoreInfo{}
|
||||||
|
}
|
||||||
|
if scoreRewards == nil {
|
||||||
|
scoreRewards = []*pb.BigHuntReward{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.FinishBigHuntQuestResponse{
|
||||||
|
ScoreInfo: scoreInfo,
|
||||||
|
ScoreReward: scoreRewards,
|
||||||
|
BattleReport: &pb.BigHuntBattleReport{
|
||||||
|
BattleReportWave: []*pb.BigHuntBattleReportWave{},
|
||||||
|
},
|
||||||
|
DiffUserData: buildBigHuntDiff(user, append([]string{
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserMaterial",
|
||||||
|
}, bigHuntDiffTables...)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BigHuntServiceServer) RestartBigHuntQuest(ctx context.Context, req *pb.RestartBigHuntQuestRequest) (*pb.RestartBigHuntQuestResponse, error) {
|
||||||
|
log.Printf("[BigHuntService] RestartBigHuntQuest: bossQuestId=%d questId=%d", req.BigHuntBossQuestId, req.BigHuntQuestId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
bhQuest := s.catalog.QuestById[req.BigHuntQuestId]
|
||||||
|
|
||||||
|
var battleBinary []byte
|
||||||
|
var deckNumber int32
|
||||||
|
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
s.engine.HandleBigHuntQuestStart(user, bhQuest.QuestId, user.BigHuntDeckNumber, nowMillis)
|
||||||
|
|
||||||
|
user.BigHuntProgress.CurrentQuestSceneId = 0
|
||||||
|
user.BigHuntProgress.LatestVersion = nowMillis
|
||||||
|
|
||||||
|
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
|
||||||
|
st.DailyChallengeCount++
|
||||||
|
st.LatestChallengeDatetime = nowMillis
|
||||||
|
st.LatestVersion = nowMillis
|
||||||
|
user.BigHuntStatuses[req.BigHuntBossQuestId] = st
|
||||||
|
|
||||||
|
battleBinary = user.BigHuntBattleBinary
|
||||||
|
deckNumber = user.BigHuntDeckNumber
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.RestartBigHuntQuestResponse{
|
||||||
|
BattleBinary: battleBinary,
|
||||||
|
DeckNumber: deckNumber,
|
||||||
|
DiffUserData: buildBigHuntDiff(user, append([]string{"IUserQuest"}, bigHuntDiffTables...)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BigHuntServiceServer) SkipBigHuntQuest(ctx context.Context, req *pb.SkipBigHuntQuestRequest) (*pb.SkipBigHuntQuestResponse, error) {
|
||||||
|
log.Printf("[BigHuntService] SkipBigHuntQuest: bossQuestId=%d skipCount=%d", req.BigHuntBossQuestId, req.SkipCount)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
|
||||||
|
st.DailyChallengeCount += req.SkipCount
|
||||||
|
st.LatestChallengeDatetime = nowMillis
|
||||||
|
st.LatestVersion = nowMillis
|
||||||
|
user.BigHuntStatuses[req.BigHuntBossQuestId] = st
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.SkipBigHuntQuestResponse{
|
||||||
|
ScoreReward: []*pb.BigHuntReward{},
|
||||||
|
DiffUserData: buildBigHuntDiff(user, bigHuntDiffTables),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BigHuntServiceServer) SaveBigHuntBattleInfo(ctx context.Context, req *pb.SaveBigHuntBattleInfoRequest) (*pb.SaveBigHuntBattleInfoResponse, error) {
|
||||||
|
log.Printf("[BigHuntService] SaveBigHuntBattleInfo: elapsedFrames=%d", req.ElapsedFrameCount)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
var totalDamage int64
|
||||||
|
if req.BigHuntBattleDetail != nil {
|
||||||
|
for _, ci := range req.BigHuntBattleDetail.CostumeBattleInfo {
|
||||||
|
if ci != nil {
|
||||||
|
totalDamage += ci.TotalDamage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.BigHuntBattleBinary = req.BattleBinary
|
||||||
|
|
||||||
|
if req.BigHuntBattleDetail != nil {
|
||||||
|
user.BigHuntBattleDetail = store.BigHuntBattleDetail{
|
||||||
|
DeckType: req.BigHuntBattleDetail.DeckType,
|
||||||
|
UserTripleDeckNumber: req.BigHuntBattleDetail.UserTripleDeckNumber,
|
||||||
|
BossKnockDownCount: req.BigHuntBattleDetail.BossKnockDownCount,
|
||||||
|
MaxComboCount: req.BigHuntBattleDetail.MaxComboCount,
|
||||||
|
TotalDamage: totalDamage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.BigHuntProgress.LatestVersion = nowMillis
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.SaveBigHuntBattleInfoResponse{
|
||||||
|
DiffUserData: buildBigHuntDiff(user, []string{"IUserBigHuntProgressStatus"}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BigHuntServiceServer) GetBigHuntTopData(ctx context.Context, _ *emptypb.Empty) (*pb.GetBigHuntTopDataResponse, error) {
|
||||||
|
log.Printf("[BigHuntService] GetBigHuntTopData")
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.SnapshotUser(userId)
|
||||||
|
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
weeklyVersion := gametime.WeeklyVersion(nowMillis)
|
||||||
|
|
||||||
|
var weeklyScoreResults []*pb.WeeklyScoreResult
|
||||||
|
for _, boss := range s.catalog.BossByBossId {
|
||||||
|
key := store.BigHuntWeeklyScoreKey{
|
||||||
|
BigHuntWeeklyVersion: weeklyVersion,
|
||||||
|
AttributeType: boss.AttributeType,
|
||||||
|
}
|
||||||
|
ws := user.BigHuntWeeklyMaxScores[key]
|
||||||
|
gradeIconId := s.catalog.ResolveGradeIconId(boss.BigHuntBossId, ws.MaxScore)
|
||||||
|
|
||||||
|
weeklyScoreResults = append(weeklyScoreResults, &pb.WeeklyScoreResult{
|
||||||
|
AttributeType: boss.AttributeType,
|
||||||
|
BeforeMaxScore: ws.MaxScore,
|
||||||
|
CurrentMaxScore: ws.MaxScore,
|
||||||
|
BeforeAssetGradeIconId: gradeIconId,
|
||||||
|
CurrentAssetGradeIconId: gradeIconId,
|
||||||
|
AfterMaxScore: ws.MaxScore,
|
||||||
|
AfterAssetGradeIconId: gradeIconId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ws := user.BigHuntWeeklyStatuses[weeklyVersion]
|
||||||
|
|
||||||
|
weeklyRewards := s.resolveWeeklyRewards(user, weeklyVersion, nowMillis)
|
||||||
|
|
||||||
|
lastWeekVersion := weeklyVersion - 7*24*60*60*1000
|
||||||
|
lastWeekRewards := s.resolveWeeklyRewards(user, lastWeekVersion, nowMillis)
|
||||||
|
|
||||||
|
return &pb.GetBigHuntTopDataResponse{
|
||||||
|
WeeklyScoreResult: weeklyScoreResults,
|
||||||
|
WeeklyScoreReward: weeklyRewards,
|
||||||
|
IsReceivedWeeklyScoreReward: ws.IsReceivedWeeklyReward,
|
||||||
|
LastWeekWeeklyScoreReward: lastWeekRewards,
|
||||||
|
DiffUserData: buildBigHuntDiff(user, bigHuntDiffTables),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BigHuntServiceServer) resolveWeeklyRewards(user store.UserState, weeklyVersion, nowMillis int64) []*pb.BigHuntReward {
|
||||||
|
var rewards []*pb.BigHuntReward
|
||||||
|
for _, boss := range s.catalog.BossByBossId {
|
||||||
|
rewardKey := masterdata.BigHuntWeeklyRewardKey{
|
||||||
|
ScheduleId: 1,
|
||||||
|
AttributeType: boss.AttributeType,
|
||||||
|
}
|
||||||
|
rewardGroupId := s.catalog.ResolveActiveWeeklyRewardGroupId(rewardKey, nowMillis)
|
||||||
|
if rewardGroupId == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
weekKey := store.BigHuntWeeklyScoreKey{
|
||||||
|
BigHuntWeeklyVersion: weeklyVersion,
|
||||||
|
AttributeType: boss.AttributeType,
|
||||||
|
}
|
||||||
|
maxScore := user.BigHuntWeeklyMaxScores[weekKey].MaxScore
|
||||||
|
for _, item := range s.catalog.CollectNewRewards(rewardGroupId, 0, maxScore) {
|
||||||
|
rewards = append(rewards, &pb.BigHuntReward{
|
||||||
|
PossessionType: item.PossessionType,
|
||||||
|
PossessionId: item.PossessionId,
|
||||||
|
Count: item.Count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rewards == nil {
|
||||||
|
rewards = []*pb.BigHuntReward{}
|
||||||
|
}
|
||||||
|
return rewards
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/questflow"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
|
||||||
|
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) StartEventQuest(ctx context.Context, req *pb.StartEventQuestRequest) (*pb.StartEventQuestResponse, error) {
|
||||||
|
log.Printf("[QuestService] StartEventQuest: chapterId=%d questId=%d isBattleOnly=%v", req.EventQuestChapterId, req.QuestId, req.IsBattleOnly)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
s.engine.HandleEventQuestStart(user, req.EventQuestChapterId, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis)
|
||||||
|
})
|
||||||
|
|
||||||
|
drops := s.engine.BattleDropRewards(req.QuestId)
|
||||||
|
pbDrops := make([]*pb.BattleDropReward, len(drops))
|
||||||
|
for i, d := range drops {
|
||||||
|
pbDrops[i] = &pb.BattleDropReward{
|
||||||
|
QuestSceneId: d.QuestSceneId,
|
||||||
|
BattleDropCategoryId: d.BattleDropCategoryId,
|
||||||
|
BattleDropEffectId: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.StartEventQuestResponse{
|
||||||
|
BattleDropReward: pbDrops,
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserStatus",
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserQuestMission",
|
||||||
|
"IUserEventQuestProgressStatus",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) FinishEventQuest(ctx context.Context, req *pb.FinishEventQuestRequest) (*pb.FinishEventQuestResponse, error) {
|
||||||
|
log.Printf("[QuestService] FinishEventQuest: chapterId=%d questId=%d isRetired=%v isAnnihilated=%v", req.EventQuestChapterId, req.QuestId, req.IsRetired, req.IsAnnihilated)
|
||||||
|
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
var outcome questflow.FinishOutcome
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
outcome = s.engine.HandleEventQuestFinish(user, req.EventQuestChapterId, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis)
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.FinishEventQuestResponse{
|
||||||
|
DropReward: toProtoRewards(outcome.DropRewards),
|
||||||
|
FirstClearReward: toProtoRewards(outcome.FirstClearRewards),
|
||||||
|
MissionClearReward: toProtoRewards(outcome.MissionClearRewards),
|
||||||
|
MissionClearCompleteReward: toProtoRewards(outcome.MissionClearCompleteRewards),
|
||||||
|
AutoOrbitResult: []*pb.QuestReward{},
|
||||||
|
IsBigWin: outcome.IsBigWin,
|
||||||
|
BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds,
|
||||||
|
UserStatusCampaignReward: []*pb.QuestReward{},
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserQuestMission",
|
||||||
|
"IUserEventQuestProgressStatus",
|
||||||
|
"IUserStatus",
|
||||||
|
"IUserGem",
|
||||||
|
"IUserCharacter",
|
||||||
|
"IUserCostume",
|
||||||
|
"IUserCostumeActiveSkill",
|
||||||
|
"IUserWeapon",
|
||||||
|
"IUserWeaponSkill",
|
||||||
|
"IUserWeaponAbility",
|
||||||
|
"IUserWeaponNote",
|
||||||
|
"IUserWeaponStory",
|
||||||
|
"IUserCompanion",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserImportantItem",
|
||||||
|
"IUserParts",
|
||||||
|
"IUserPartsGroupNote",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) RestartEventQuest(ctx context.Context, req *pb.RestartEventQuestRequest) (*pb.RestartEventQuestResponse, error) {
|
||||||
|
log.Printf("[QuestService] RestartEventQuest: chapterId=%d questId=%d", req.EventQuestChapterId, req.QuestId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
s.engine.HandleEventQuestRestart(user, req.EventQuestChapterId, req.QuestId, gametime.NowMillis())
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.RestartEventQuestResponse{
|
||||||
|
BattleDropReward: []*pb.BattleDropReward{},
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserQuestMission",
|
||||||
|
"IUserEventQuestProgressStatus",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) UpdateEventQuestSceneProgress(ctx context.Context, req *pb.UpdateEventQuestSceneProgressRequest) (*pb.UpdateEventQuestSceneProgressResponse, error) {
|
||||||
|
log.Printf("[QuestService] UpdateEventQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
s.engine.HandleEventQuestSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.UpdateEventQuestSceneProgressResponse{
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserEventQuestProgressStatus",
|
||||||
|
"IUserCharacter",
|
||||||
|
"IUserCostume",
|
||||||
|
"IUserWeapon",
|
||||||
|
"IUserWeaponSkill",
|
||||||
|
"IUserWeaponAbility",
|
||||||
|
"IUserCompanion",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserImportantItem",
|
||||||
|
"IUserParts",
|
||||||
|
"IUserPartsGroupNote",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultGuerrillaFreeOpenMinutes = int32(60)
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) StartGuerrillaFreeOpen(ctx context.Context, req *emptypb.Empty) (*pb.StartGuerrillaFreeOpenResponse, error) {
|
||||||
|
log.Printf("[QuestService] StartGuerrillaFreeOpen")
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.GuerrillaFreeOpen.StartDatetime = nowMillis
|
||||||
|
user.GuerrillaFreeOpen.OpenMinutes = defaultGuerrillaFreeOpenMinutes
|
||||||
|
user.GuerrillaFreeOpen.DailyOpenedCount++
|
||||||
|
user.GuerrillaFreeOpen.LatestVersion = nowMillis
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.StartGuerrillaFreeOpenResponse{
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{"IUserEventQuestGuerrillaFreeOpen"}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/questflow"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) StartExtraQuest(ctx context.Context, req *pb.StartExtraQuestRequest) (*pb.StartExtraQuestResponse, error) {
|
||||||
|
log.Printf("[QuestService] StartExtraQuest: questId=%d deckNumber=%d", req.QuestId, req.UserDeckNumber)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
s.engine.HandleExtraQuestStart(user, req.QuestId, req.UserDeckNumber, nowMillis)
|
||||||
|
})
|
||||||
|
|
||||||
|
drops := s.engine.BattleDropRewards(req.QuestId)
|
||||||
|
pbDrops := make([]*pb.BattleDropReward, len(drops))
|
||||||
|
for i, d := range drops {
|
||||||
|
pbDrops[i] = &pb.BattleDropReward{
|
||||||
|
QuestSceneId: d.QuestSceneId,
|
||||||
|
BattleDropCategoryId: d.BattleDropCategoryId,
|
||||||
|
BattleDropEffectId: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.StartExtraQuestResponse{
|
||||||
|
BattleDropReward: pbDrops,
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserStatus",
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserQuestMission",
|
||||||
|
"IUserExtraQuestProgressStatus",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) FinishExtraQuest(ctx context.Context, req *pb.FinishExtraQuestRequest) (*pb.FinishExtraQuestResponse, error) {
|
||||||
|
log.Printf("[QuestService] FinishExtraQuest: questId=%d isRetired=%v isAnnihilated=%v", req.QuestId, req.IsRetired, req.IsAnnihilated)
|
||||||
|
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
var outcome questflow.FinishOutcome
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
outcome = s.engine.HandleExtraQuestFinish(user, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis)
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.FinishExtraQuestResponse{
|
||||||
|
DropReward: toProtoRewards(outcome.DropRewards),
|
||||||
|
FirstClearReward: toProtoRewards(outcome.FirstClearRewards),
|
||||||
|
MissionClearReward: toProtoRewards(outcome.MissionClearRewards),
|
||||||
|
MissionClearCompleteReward: toProtoRewards(outcome.MissionClearCompleteRewards),
|
||||||
|
IsBigWin: outcome.IsBigWin,
|
||||||
|
BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds,
|
||||||
|
UserStatusCampaignReward: []*pb.QuestReward{},
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserQuestMission",
|
||||||
|
"IUserExtraQuestProgressStatus",
|
||||||
|
"IUserStatus",
|
||||||
|
"IUserGem",
|
||||||
|
"IUserCharacter",
|
||||||
|
"IUserCostume",
|
||||||
|
"IUserCostumeActiveSkill",
|
||||||
|
"IUserWeapon",
|
||||||
|
"IUserWeaponSkill",
|
||||||
|
"IUserWeaponAbility",
|
||||||
|
"IUserWeaponNote",
|
||||||
|
"IUserWeaponStory",
|
||||||
|
"IUserCompanion",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserImportantItem",
|
||||||
|
"IUserParts",
|
||||||
|
"IUserPartsGroupNote",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) RestartExtraQuest(ctx context.Context, req *pb.RestartExtraQuestRequest) (*pb.RestartExtraQuestResponse, error) {
|
||||||
|
log.Printf("[QuestService] RestartExtraQuest: questId=%d", req.QuestId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
s.engine.HandleExtraQuestRestart(user, req.QuestId, gametime.NowMillis())
|
||||||
|
})
|
||||||
|
|
||||||
|
drops := s.engine.BattleDropRewards(req.QuestId)
|
||||||
|
pbDrops := make([]*pb.BattleDropReward, len(drops))
|
||||||
|
for i, d := range drops {
|
||||||
|
pbDrops[i] = &pb.BattleDropReward{
|
||||||
|
QuestSceneId: d.QuestSceneId,
|
||||||
|
BattleDropCategoryId: d.BattleDropCategoryId,
|
||||||
|
BattleDropEffectId: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.RestartExtraQuestResponse{
|
||||||
|
BattleDropReward: pbDrops,
|
||||||
|
DeckNumber: user.Quests[req.QuestId].UserDeckNumber,
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserQuestMission",
|
||||||
|
"IUserExtraQuestProgressStatus",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) UpdateExtraQuestSceneProgress(ctx context.Context, req *pb.UpdateExtraQuestSceneProgressRequest) (*pb.UpdateExtraQuestSceneProgressResponse, error) {
|
||||||
|
log.Printf("[QuestService] UpdateExtraQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
s.engine.HandleExtraQuestSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.UpdateExtraQuestSceneProgressResponse{
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserExtraQuestProgressStatus",
|
||||||
|
"IUserCharacter",
|
||||||
|
"IUserCostume",
|
||||||
|
"IUserWeapon",
|
||||||
|
"IUserWeaponSkill",
|
||||||
|
"IUserWeaponAbility",
|
||||||
|
"IUserCompanion",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserImportantItem",
|
||||||
|
"IUserParts",
|
||||||
|
"IUserPartsGroupNote",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/questflow"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
|
||||||
|
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type QuestServiceServer struct {
|
||||||
|
pb.UnimplementedQuestServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
engine *questflow.QuestHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQuestServiceServer(users store.UserRepository, sessions store.SessionRepository, engine *questflow.QuestHandler) *QuestServiceServer {
|
||||||
|
if engine == nil {
|
||||||
|
panic("quest handler is required")
|
||||||
|
}
|
||||||
|
return &QuestServiceServer{users: users, sessions: sessions, engine: engine}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSelectedQuestDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData {
|
||||||
|
tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames)
|
||||||
|
return userdata.BuildDiffFromTablesOrdered(tables, tableNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) UpdateMainFlowSceneProgress(ctx context.Context, req *pb.UpdateMainFlowSceneProgressRequest) (*pb.UpdateMainFlowSceneProgressResponse, error) {
|
||||||
|
log.Printf("[QuestService] UpdateMainFlowSceneProgress: questSceneId=%d", req.QuestSceneId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
s.engine.HandleMainFlowSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.UpdateMainFlowSceneProgressResponse{
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserMainQuestFlowStatus",
|
||||||
|
"IUserMainQuestMainFlowStatus",
|
||||||
|
"IUserMainQuestProgressStatus",
|
||||||
|
"IUserMainQuestSeasonRoute",
|
||||||
|
"IUserPortalCageStatus",
|
||||||
|
"IUserSideStoryQuestSceneProgressStatus",
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserCharacter",
|
||||||
|
"IUserCostume",
|
||||||
|
"IUserCostumeActiveSkill",
|
||||||
|
"IUserWeapon",
|
||||||
|
"IUserWeaponSkill",
|
||||||
|
"IUserWeaponAbility",
|
||||||
|
"IUserWeaponNote",
|
||||||
|
"IUserWeaponStory",
|
||||||
|
"IUserCompanion",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserImportantItem",
|
||||||
|
"IUserParts",
|
||||||
|
"IUserPartsGroupNote",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) UpdateReplayFlowSceneProgress(ctx context.Context, req *pb.UpdateReplayFlowSceneProgressRequest) (*pb.UpdateReplayFlowSceneProgressResponse, error) {
|
||||||
|
log.Printf("[QuestService] UpdateReplayFlowSceneProgress: questSceneId=%d", req.QuestSceneId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
s.engine.HandleReplayFlowSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.UpdateReplayFlowSceneProgressResponse{
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserMainQuestFlowStatus",
|
||||||
|
"IUserMainQuestReplayFlowStatus",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) UpdateMainQuestSceneProgress(ctx context.Context, req *pb.UpdateMainQuestSceneProgressRequest) (*pb.UpdateMainQuestSceneProgressResponse, error) {
|
||||||
|
log.Printf("[QuestService] UpdateMainQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
s.engine.HandleMainQuestSceneProgress(user, req.QuestSceneId)
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.UpdateMainQuestSceneProgressResponse{
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserStatus",
|
||||||
|
"IUserCharacter",
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserQuestMission",
|
||||||
|
"IUserMainQuestFlowStatus",
|
||||||
|
"IUserMainQuestMainFlowStatus",
|
||||||
|
"IUserMainQuestProgressStatus",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) StartMainQuest(ctx context.Context, req *pb.StartMainQuestRequest) (*pb.StartMainQuestResponse, error) {
|
||||||
|
log.Printf("[QuestService] StartMainQuest: %+v", req)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
if req.IsReplayFlow {
|
||||||
|
s.engine.HandleQuestStartReplay(user, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis)
|
||||||
|
} else {
|
||||||
|
s.engine.HandleQuestStart(user, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
drops := s.engine.BattleDropRewards(req.QuestId)
|
||||||
|
pbDrops := make([]*pb.BattleDropReward, len(drops))
|
||||||
|
for i, d := range drops {
|
||||||
|
pbDrops[i] = &pb.BattleDropReward{
|
||||||
|
QuestSceneId: d.QuestSceneId,
|
||||||
|
BattleDropCategoryId: d.BattleDropCategoryId,
|
||||||
|
BattleDropEffectId: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.StartMainQuestResponse{
|
||||||
|
BattleDropReward: pbDrops,
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserStatus",
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserQuestMission",
|
||||||
|
"IUserMainQuestFlowStatus",
|
||||||
|
"IUserMainQuestMainFlowStatus",
|
||||||
|
"IUserMainQuestProgressStatus",
|
||||||
|
"IUserMainQuestSeasonRoute",
|
||||||
|
"IUserMainQuestReplayFlowStatus",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProtoRewards(grants []questflow.RewardGrant) []*pb.QuestReward {
|
||||||
|
if len(grants) == 0 {
|
||||||
|
return []*pb.QuestReward{}
|
||||||
|
}
|
||||||
|
out := make([]*pb.QuestReward, len(grants))
|
||||||
|
for i, g := range grants {
|
||||||
|
out[i] = &pb.QuestReward{
|
||||||
|
PossessionType: int32(g.PossessionType),
|
||||||
|
PossessionId: g.PossessionId,
|
||||||
|
Count: g.Count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) FinishMainQuest(ctx context.Context, req *pb.FinishMainQuestRequest) (*pb.FinishMainQuestResponse, error) {
|
||||||
|
log.Printf("[QuestService] FinishMainQuest: questId=%d isMainFlow=%v isRetired=%v isAnnihilated=%v storySkipType=%d",
|
||||||
|
req.QuestId, req.IsMainFlow, req.IsRetired, req.IsAnnihilated, req.StorySkipType)
|
||||||
|
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
var outcome questflow.FinishOutcome
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
outcome = s.engine.HandleQuestFinish(user, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis)
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.FinishMainQuestResponse{
|
||||||
|
DropReward: toProtoRewards(outcome.DropRewards),
|
||||||
|
FirstClearReward: toProtoRewards(outcome.FirstClearRewards),
|
||||||
|
MissionClearReward: toProtoRewards(outcome.MissionClearRewards),
|
||||||
|
MissionClearCompleteReward: toProtoRewards(outcome.MissionClearCompleteRewards),
|
||||||
|
AutoOrbitResult: []*pb.QuestReward{},
|
||||||
|
IsBigWin: outcome.IsBigWin,
|
||||||
|
BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds,
|
||||||
|
ReplayFlowFirstClearReward: toProtoRewards(outcome.ReplayFlowFirstClearRewards),
|
||||||
|
UserStatusCampaignReward: []*pb.QuestReward{},
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserQuestMission",
|
||||||
|
"IUserMainQuestFlowStatus",
|
||||||
|
"IUserMainQuestMainFlowStatus",
|
||||||
|
"IUserMainQuestProgressStatus",
|
||||||
|
"IUserMainQuestSeasonRoute",
|
||||||
|
"IUserMainQuestReplayFlowStatus",
|
||||||
|
"IUserStatus",
|
||||||
|
"IUserGem",
|
||||||
|
"IUserCharacter",
|
||||||
|
"IUserCostume",
|
||||||
|
"IUserCostumeActiveSkill",
|
||||||
|
"IUserWeapon",
|
||||||
|
"IUserWeaponSkill",
|
||||||
|
"IUserWeaponAbility",
|
||||||
|
"IUserWeaponNote",
|
||||||
|
"IUserWeaponStory",
|
||||||
|
"IUserCompanion",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserImportantItem",
|
||||||
|
"IUserParts",
|
||||||
|
"IUserPartsGroupNote",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) RestartMainQuest(ctx context.Context, req *pb.RestartMainQuestRequest) (*pb.RestartMainQuestResponse, error) {
|
||||||
|
log.Printf("[QuestService] RestartMainQuest: questId=%d isMainFlow=%v", req.QuestId, req.IsMainFlow)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
s.engine.HandleQuestRestart(user, req.QuestId, gametime.NowMillis())
|
||||||
|
})
|
||||||
|
|
||||||
|
drops := s.engine.BattleDropRewards(req.QuestId)
|
||||||
|
pbDrops := make([]*pb.BattleDropReward, len(drops))
|
||||||
|
for i, d := range drops {
|
||||||
|
pbDrops[i] = &pb.BattleDropReward{
|
||||||
|
QuestSceneId: d.QuestSceneId,
|
||||||
|
BattleDropCategoryId: d.BattleDropCategoryId,
|
||||||
|
BattleDropEffectId: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.RestartMainQuestResponse{
|
||||||
|
BattleDropReward: pbDrops,
|
||||||
|
DeckNumber: user.Quests[req.QuestId].UserDeckNumber,
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserStatus",
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserQuestMission",
|
||||||
|
"IUserMainQuestFlowStatus",
|
||||||
|
"IUserMainQuestMainFlowStatus",
|
||||||
|
"IUserMainQuestProgressStatus",
|
||||||
|
"IUserMainQuestSeasonRoute",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) FinishAutoOrbit(ctx context.Context, req *emptypb.Empty) (*pb.FinishAutoOrbitResponse, error) {
|
||||||
|
log.Printf("[QuestService] FinishAutoOrbit")
|
||||||
|
return &pb.FinishAutoOrbitResponse{
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) SkipQuest(ctx context.Context, req *pb.SkipQuestRequest) (*pb.SkipQuestResponse, error) {
|
||||||
|
log.Printf("[QuestService] SkipQuest: questId=%d skipCount=%d useEffectItems=%d", req.QuestId, req.SkipCount, len(req.UseEffectItem))
|
||||||
|
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
var outcome questflow.FinishOutcome
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
for _, item := range req.UseEffectItem {
|
||||||
|
log.Printf("[QuestService] SkipQuest UseEffectItem: consumableItemId=%d count=%d", item.ConsumableItemId, item.Count)
|
||||||
|
user.ConsumableItems[item.ConsumableItemId] -= item.Count
|
||||||
|
if user.ConsumableItems[item.ConsumableItemId] < 0 {
|
||||||
|
user.ConsumableItems[item.ConsumableItemId] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome = s.engine.HandleQuestSkip(user, req.QuestId, req.SkipCount, nowMillis)
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.SkipQuestResponse{
|
||||||
|
DropReward: toProtoRewards(outcome.DropRewards),
|
||||||
|
UserStatusCampaignReward: []*pb.QuestReward{},
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserStatus",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserParts",
|
||||||
|
"IUserPartsGroupNote",
|
||||||
|
"IUserCharacter",
|
||||||
|
"IUserCostume",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) SetRoute(ctx context.Context, req *pb.SetRouteRequest) (*pb.SetRouteResponse, error) {
|
||||||
|
log.Printf("[QuestService] SetRoute: mainQuestRouteId=%d", req.MainQuestRouteId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.MainQuest.CurrentMainQuestRouteId = req.MainQuestRouteId
|
||||||
|
if seasonId, ok := s.engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok {
|
||||||
|
user.MainQuest.MainQuestSeasonId = seasonId
|
||||||
|
}
|
||||||
|
now := gametime.NowMillis()
|
||||||
|
user.PortalCageStatus.IsCurrentProgress = false
|
||||||
|
user.PortalCageStatus.LatestVersion = now
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.SetRouteResponse{
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserMainQuestSeasonRoute",
|
||||||
|
"IUserMainQuestMainFlowStatus",
|
||||||
|
"IUserPortalCageStatus",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) SetQuestSceneChoice(ctx context.Context, req *pb.SetQuestSceneChoiceRequest) (*pb.SetQuestSceneChoiceResponse, error) {
|
||||||
|
log.Printf("[QuestService] SetQuestSceneChoice: questSceneId=%d choiceNumber=%d",
|
||||||
|
req.QuestSceneId, req.ChoiceNumber)
|
||||||
|
return &pb.SetQuestSceneChoiceResponse{
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) ResetLimitContentQuestProgress(ctx context.Context, req *pb.ResetLimitContentQuestProgressRequest) (*pb.ResetLimitContentQuestProgressResponse, error) {
|
||||||
|
log.Printf("[QuestService] ResetLimitContentQuestProgress: eventQuestChapterId=%d questId=%d",
|
||||||
|
req.EventQuestChapterId, req.QuestId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
if _, exists := user.SideStoryQuests[req.QuestId]; exists {
|
||||||
|
user.SideStoryQuests[req.QuestId] = store.SideStoryQuestProgress{
|
||||||
|
HeadSideStoryQuestSceneId: 0,
|
||||||
|
SideStoryQuestStateType: model.SideStoryQuestStateUnknown,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(user.QuestLimitContentStatus, req.QuestId)
|
||||||
|
|
||||||
|
if user.SideStoryActiveProgress.CurrentSideStoryQuestId == req.QuestId {
|
||||||
|
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.ResetLimitContentQuestProgressResponse{
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserSideStoryQuest",
|
||||||
|
"IUserSideStoryQuestSceneProgressStatus",
|
||||||
|
"IUserQuestLimitContentStatus",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestServiceServer) SetAutoSaleSetting(ctx context.Context, req *pb.SetAutoSaleSettingRequest) (*pb.SetAutoSaleSettingResponse, error) {
|
||||||
|
log.Printf("[QuestService] SetAutoSaleSetting: items=%d", len(req.AutoSaleSettingItem))
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.AutoSaleSettings = make(map[int32]store.AutoSaleSettingState, len(req.AutoSaleSettingItem))
|
||||||
|
for itemType, itemValue := range req.AutoSaleSettingItem {
|
||||||
|
user.AutoSaleSettings[itemType] = store.AutoSaleSettingState{
|
||||||
|
PossessionAutoSaleItemType: itemType,
|
||||||
|
PossessionAutoSaleItemValue: itemValue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.SetAutoSaleSettingResponse{
|
||||||
|
DiffUserData: buildSelectedQuestDiff(user, []string{
|
||||||
|
"IUserAutoSaleSettingDetail",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SideStoryQuestServiceServer struct {
|
||||||
|
pb.UnimplementedSideStoryQuestServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.SideStoryCatalog
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSideStoryQuestServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.SideStoryCatalog) *SideStoryQuestServiceServer {
|
||||||
|
return &SideStoryQuestServiceServer{users: users, sessions: sessions, catalog: catalog}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSideStoryDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData {
|
||||||
|
tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames)
|
||||||
|
return userdata.BuildDiffFromTablesOrdered(tables, tableNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SideStoryQuestServiceServer) MoveSideStoryQuestProgress(ctx context.Context, req *pb.MoveSideStoryQuestRequest) (*pb.MoveSideStoryQuestResponse, error) {
|
||||||
|
log.Printf("[SideStoryQuestService] MoveSideStoryQuestProgress: sideStoryQuestId=%d", req.SideStoryQuestId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
firstSceneId := s.catalog.FirstSceneByQuestId[req.SideStoryQuestId]
|
||||||
|
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
existing, exists := user.SideStoryQuests[req.SideStoryQuestId]
|
||||||
|
|
||||||
|
var sceneId int32
|
||||||
|
if exists && existing.HeadSideStoryQuestSceneId > 0 {
|
||||||
|
sceneId = existing.HeadSideStoryQuestSceneId
|
||||||
|
} else {
|
||||||
|
sceneId = firstSceneId
|
||||||
|
}
|
||||||
|
|
||||||
|
user.SideStoryActiveProgress.CurrentSideStoryQuestId = req.SideStoryQuestId
|
||||||
|
user.SideStoryActiveProgress.CurrentSideStoryQuestSceneId = sceneId
|
||||||
|
user.SideStoryActiveProgress.LatestVersion = nowMillis
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
user.SideStoryQuests[req.SideStoryQuestId] = store.SideStoryQuestProgress{
|
||||||
|
HeadSideStoryQuestSceneId: firstSceneId,
|
||||||
|
SideStoryQuestStateType: model.SideStoryQuestStateActive,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.MoveSideStoryQuestResponse{
|
||||||
|
DiffUserData: buildSideStoryDiff(user, []string{
|
||||||
|
"IUserSideStoryQuest",
|
||||||
|
"IUserSideStoryQuestSceneProgressStatus",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SideStoryQuestServiceServer) UpdateSideStoryQuestSceneProgress(ctx context.Context, req *pb.UpdateSideStoryQuestSceneProgressRequest) (*pb.UpdateSideStoryQuestSceneProgressResponse, error) {
|
||||||
|
log.Printf("[SideStoryQuestService] UpdateSideStoryQuestSceneProgress: sideStoryQuestId=%d sceneId=%d",
|
||||||
|
req.SideStoryQuestId, req.SideStoryQuestSceneId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.SideStoryActiveProgress.CurrentSideStoryQuestSceneId = req.SideStoryQuestSceneId
|
||||||
|
user.SideStoryActiveProgress.LatestVersion = nowMillis
|
||||||
|
|
||||||
|
progress := user.SideStoryQuests[req.SideStoryQuestId]
|
||||||
|
if req.SideStoryQuestSceneId > progress.HeadSideStoryQuestSceneId {
|
||||||
|
progress.HeadSideStoryQuestSceneId = req.SideStoryQuestSceneId
|
||||||
|
}
|
||||||
|
progress.LatestVersion = nowMillis
|
||||||
|
user.SideStoryQuests[req.SideStoryQuestId] = progress
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.UpdateSideStoryQuestSceneProgressResponse{
|
||||||
|
DiffUserData: buildSideStoryDiff(user, []string{
|
||||||
|
"IUserSideStoryQuest",
|
||||||
|
"IUserSideStoryQuestSceneProgressStatus",
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
|
||||||
|
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RewardServiceServer struct {
|
||||||
|
pb.UnimplementedRewardServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
bhCatalog *masterdata.BigHuntCatalog
|
||||||
|
granter *store.PossessionGranter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRewardServiceServer(
|
||||||
|
users store.UserRepository,
|
||||||
|
sessions store.SessionRepository,
|
||||||
|
bhCatalog *masterdata.BigHuntCatalog,
|
||||||
|
granter *store.PossessionGranter,
|
||||||
|
) *RewardServiceServer {
|
||||||
|
return &RewardServiceServer{users: users, sessions: sessions, bhCatalog: bhCatalog, granter: granter}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RewardServiceServer) ReceiveBigHuntReward(ctx context.Context, _ *emptypb.Empty) (*pb.ReceiveBigHuntRewardResponse, error) {
|
||||||
|
log.Printf("[RewardService] ReceiveBigHuntReward")
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
weeklyVersion := gametime.WeeklyVersion(nowMillis)
|
||||||
|
|
||||||
|
var weeklyScoreResults []*pb.WeeklyScoreResult
|
||||||
|
var weeklyRewards []*pb.BigHuntReward
|
||||||
|
isReceived := false
|
||||||
|
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
ws := user.BigHuntWeeklyStatuses[weeklyVersion]
|
||||||
|
isReceived = ws.IsReceivedWeeklyReward
|
||||||
|
|
||||||
|
for _, boss := range s.bhCatalog.BossByBossId {
|
||||||
|
key := store.BigHuntWeeklyScoreKey{
|
||||||
|
BigHuntWeeklyVersion: weeklyVersion,
|
||||||
|
AttributeType: boss.AttributeType,
|
||||||
|
}
|
||||||
|
wms := user.BigHuntWeeklyMaxScores[key]
|
||||||
|
gradeIcon := s.bhCatalog.ResolveGradeIconId(boss.BigHuntBossId, wms.MaxScore)
|
||||||
|
weeklyScoreResults = append(weeklyScoreResults, &pb.WeeklyScoreResult{
|
||||||
|
AttributeType: boss.AttributeType,
|
||||||
|
BeforeMaxScore: wms.MaxScore,
|
||||||
|
CurrentMaxScore: wms.MaxScore,
|
||||||
|
BeforeAssetGradeIconId: gradeIcon,
|
||||||
|
CurrentAssetGradeIconId: gradeIcon,
|
||||||
|
AfterMaxScore: wms.MaxScore,
|
||||||
|
AfterAssetGradeIconId: gradeIcon,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isReceived {
|
||||||
|
for _, boss := range s.bhCatalog.BossByBossId {
|
||||||
|
rewardKey := masterdata.BigHuntWeeklyRewardKey{
|
||||||
|
ScheduleId: 1,
|
||||||
|
AttributeType: boss.AttributeType,
|
||||||
|
}
|
||||||
|
rewardGroupId := s.bhCatalog.ResolveActiveWeeklyRewardGroupId(rewardKey, nowMillis)
|
||||||
|
if rewardGroupId == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
weekKey := store.BigHuntWeeklyScoreKey{
|
||||||
|
BigHuntWeeklyVersion: weeklyVersion,
|
||||||
|
AttributeType: boss.AttributeType,
|
||||||
|
}
|
||||||
|
maxScore := user.BigHuntWeeklyMaxScores[weekKey].MaxScore
|
||||||
|
|
||||||
|
items := s.bhCatalog.CollectNewRewards(rewardGroupId, 0, maxScore)
|
||||||
|
for _, item := range items {
|
||||||
|
s.granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis)
|
||||||
|
weeklyRewards = append(weeklyRewards, &pb.BigHuntReward{
|
||||||
|
PossessionType: item.PossessionType,
|
||||||
|
PossessionId: item.PossessionId,
|
||||||
|
Count: item.Count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.IsReceivedWeeklyReward = true
|
||||||
|
ws.LatestVersion = nowMillis
|
||||||
|
user.BigHuntWeeklyStatuses[weeklyVersion] = ws
|
||||||
|
isReceived = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if weeklyRewards == nil {
|
||||||
|
weeklyRewards = []*pb.BigHuntReward{}
|
||||||
|
}
|
||||||
|
if weeklyScoreResults == nil {
|
||||||
|
weeklyScoreResults = []*pb.WeeklyScoreResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.SelectTables(userdata.FullClientTableMap(user), []string{
|
||||||
|
"IUserBigHuntWeeklyStatus",
|
||||||
|
"IUserBigHuntWeeklyMaxScore",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserMaterial",
|
||||||
|
})
|
||||||
|
|
||||||
|
return &pb.ReceiveBigHuntRewardResponse{
|
||||||
|
WeeklyScoreResult: weeklyScoreResults,
|
||||||
|
WeeklyScoreReward: weeklyRewards,
|
||||||
|
IsReceivedWeeklyScoreReward: isReceived,
|
||||||
|
LastWeekWeeklyScoreReward: []*pb.BigHuntReward{},
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(tables),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RewardServiceServer) ReceivePvpReward(ctx context.Context, _ *emptypb.Empty) (*pb.ReceivePvpRewardResponse, error) {
|
||||||
|
log.Printf("[RewardService] ReceivePvpReward (stub)")
|
||||||
|
return &pb.ReceivePvpRewardResponse{
|
||||||
|
DiffUserData: map[string]*pb.DiffData{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RewardServiceServer) ReceiveLabyrinthSeasonReward(ctx context.Context, _ *emptypb.Empty) (*pb.ReceiveLabyrinthSeasonRewardResponse, error) {
|
||||||
|
log.Printf("[RewardService] ReceiveLabyrinthSeasonReward (stub)")
|
||||||
|
return &pb.ReceiveLabyrinthSeasonRewardResponse{
|
||||||
|
DiffUserData: map[string]*pb.DiffData{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RewardServiceServer) ReceiveMissionPassRemainingReward(ctx context.Context, _ *emptypb.Empty) (*pb.ReceiveMissionPassRemainingRewardResponse, error) {
|
||||||
|
log.Printf("[RewardService] ReceiveMissionPassRemainingReward (stub)")
|
||||||
|
return &pb.ReceiveMissionPassRemainingRewardResponse{
|
||||||
|
DiffUserData: map[string]*pb.DiffData{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
var shopDiffTables = []string{
|
||||||
|
"IUserShopItem",
|
||||||
|
"IUserShopReplaceable",
|
||||||
|
"IUserShopReplaceableLineup",
|
||||||
|
"IUserGem",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserImportantItem",
|
||||||
|
"IUserPremiumItem",
|
||||||
|
"IUserStatus",
|
||||||
|
"IUserCostume",
|
||||||
|
"IUserCostumeActiveSkill",
|
||||||
|
"IUserCharacter",
|
||||||
|
"IUserWeapon",
|
||||||
|
"IUserWeaponSkill",
|
||||||
|
"IUserWeaponAbility",
|
||||||
|
"IUserWeaponNote",
|
||||||
|
"IUserWeaponStory",
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShopServiceServer struct {
|
||||||
|
pb.UnimplementedShopServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.ShopCatalog
|
||||||
|
granter *store.PossessionGranter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewShopServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.ShopCatalog, granter *store.PossessionGranter) *ShopServiceServer {
|
||||||
|
return &ShopServiceServer{users: users, sessions: sessions, catalog: catalog, granter: granter}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShopServiceServer) Buy(ctx context.Context, req *pb.BuyRequest) (*pb.BuyResponse, error) {
|
||||||
|
log.Printf("[ShopService] Buy: shopId=%d items=%v", req.ShopId, req.ShopItems)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
for shopItemId, qty := range req.ShopItems {
|
||||||
|
item, ok := s.catalog.Items[shopItemId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[ShopService] Buy: unknown shopItemId=%d, skipping", shopItemId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPrice := item.Price * qty
|
||||||
|
if err := store.DeductPrice(user, item.PriceType, item.PriceId, totalPrice); err != nil {
|
||||||
|
log.Printf("[ShopService] Buy: deduct failed shopItemId=%d: %v", shopItemId, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, content := range s.catalog.Contents[shopItemId] {
|
||||||
|
s.granter.GrantFull(user,
|
||||||
|
model.PossessionType(content.PossessionType),
|
||||||
|
content.PossessionId,
|
||||||
|
content.Count*qty,
|
||||||
|
nowMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.applyContentEffects(user, shopItemId, qty, nowMillis)
|
||||||
|
|
||||||
|
si := user.ShopItems[shopItemId]
|
||||||
|
si.ShopItemId = shopItemId
|
||||||
|
si.BoughtCount += qty
|
||||||
|
si.LatestBoughtCountChangedDatetime = nowMillis
|
||||||
|
si.LatestVersion = nowMillis
|
||||||
|
user.ShopItems[shopItemId] = si
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("shop buy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables))
|
||||||
|
|
||||||
|
return &pb.BuyResponse{
|
||||||
|
OverflowPossession: []*pb.Possession{},
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShopServiceServer) RefreshUserData(ctx context.Context, req *pb.RefreshRequest) (*pb.RefreshResponse, error) {
|
||||||
|
log.Printf("[ShopService] RefreshUserData: isGemUsed=%v", req.IsGemUsed)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
if len(user.ShopReplaceableLineup) == 0 && len(s.catalog.ItemShopPool) > 0 {
|
||||||
|
for i, itemId := range s.catalog.ItemShopPool {
|
||||||
|
slot := int32(i + 1)
|
||||||
|
user.ShopReplaceableLineup[slot] = store.UserShopReplaceableLineupState{
|
||||||
|
SlotNumber: slot,
|
||||||
|
ShopItemId: itemId,
|
||||||
|
LatestVersion: nowMillis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.IsGemUsed {
|
||||||
|
user.ShopReplaceable.LineupUpdateCount++
|
||||||
|
user.ShopReplaceable.LatestLineupUpdateDatetime = nowMillis
|
||||||
|
for _, itemId := range s.catalog.ItemShopPool {
|
||||||
|
if si, ok := user.ShopItems[itemId]; ok {
|
||||||
|
si.BoughtCount = 0
|
||||||
|
si.LatestVersion = nowMillis
|
||||||
|
user.ShopItems[itemId] = si
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("shop refresh: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables))
|
||||||
|
|
||||||
|
return &pb.RefreshResponse{
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShopServiceServer) GetCesaLimit(_ context.Context, _ *emptypb.Empty) (*pb.GetCesaLimitResponse, error) {
|
||||||
|
log.Printf("[ShopService] GetCesaLimit")
|
||||||
|
return &pb.GetCesaLimitResponse{
|
||||||
|
CesaLimit: []*pb.CesaLimit{},
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShopServiceServer) CreatePurchaseTransaction(ctx context.Context, req *pb.CreatePurchaseTransactionRequest) (*pb.CreatePurchaseTransactionResponse, error) {
|
||||||
|
log.Printf("[ShopService] CreatePurchaseTransaction: shopId=%d shopItemId=%d productId=%s",
|
||||||
|
req.ShopId, req.ShopItemId, req.ProductId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
item, ok := s.catalog.Items[req.ShopItemId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[ShopService] CreatePurchaseTransaction: unknown shopItemId=%d", req.ShopItemId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.DeductPrice(user, item.PriceType, item.PriceId, item.Price); err != nil {
|
||||||
|
log.Printf("[ShopService] CreatePurchaseTransaction: deduct failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, content := range s.catalog.Contents[req.ShopItemId] {
|
||||||
|
s.granter.GrantFull(user,
|
||||||
|
model.PossessionType(content.PossessionType),
|
||||||
|
content.PossessionId,
|
||||||
|
content.Count,
|
||||||
|
nowMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.applyContentEffects(user, req.ShopItemId, 1, nowMillis)
|
||||||
|
|
||||||
|
si := user.ShopItems[req.ShopItemId]
|
||||||
|
si.ShopItemId = req.ShopItemId
|
||||||
|
si.BoughtCount++
|
||||||
|
if item.ShopItemLimitedStockId > 0 {
|
||||||
|
if maxCount, ok := s.catalog.LimitedStock[item.ShopItemLimitedStockId]; ok && si.BoughtCount >= maxCount {
|
||||||
|
si.BoughtCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
si.LatestBoughtCountChangedDatetime = nowMillis
|
||||||
|
si.LatestVersion = nowMillis
|
||||||
|
user.ShopItems[req.ShopItemId] = si
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create purchase transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
txId := fmt.Sprintf("tx_%d_%d_%d", userId, req.ShopItemId, nowMillis)
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables))
|
||||||
|
|
||||||
|
return &pb.CreatePurchaseTransactionResponse{
|
||||||
|
PurchaseTransactionId: txId,
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShopServiceServer) PurchaseGooglePlayStoreProduct(ctx context.Context, req *pb.PurchaseGooglePlayStoreProductRequest) (*pb.PurchaseGooglePlayStoreProductResponse, error) {
|
||||||
|
log.Printf("[ShopService] PurchaseGooglePlayStoreProduct: txId=%s", req.PurchaseTransactionId)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
snapshot, err := s.users.SnapshotUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("purchase google play: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables))
|
||||||
|
|
||||||
|
return &pb.PurchaseGooglePlayStoreProductResponse{
|
||||||
|
OverflowPossession: []*pb.Possession{},
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShopServiceServer) applyContentEffects(user *store.UserState, shopItemId, qty int32, nowMillis int64) {
|
||||||
|
for _, effect := range s.catalog.Effects[shopItemId] {
|
||||||
|
switch effect.EffectTargetType {
|
||||||
|
case model.EffectTargetStaminaRecovery:
|
||||||
|
maxMillis := s.catalog.MaxStaminaMillis[user.Status.Level]
|
||||||
|
millis := s.resolveEffectMillis(effect.EffectValueType, effect.EffectValue, user.Status.Level)
|
||||||
|
store.RecoverStamina(user, millis*qty, maxMillis, nowMillis)
|
||||||
|
default:
|
||||||
|
log.Printf("[ShopService] unhandled effect: shopItemId=%d targetType=%d", shopItemId, effect.EffectTargetType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShopServiceServer) resolveEffectMillis(effectValueType, effectValue, userLevel int32) int32 {
|
||||||
|
switch effectValueType {
|
||||||
|
case model.EffectValueFixed:
|
||||||
|
return effectValue
|
||||||
|
case model.EffectValuePermil:
|
||||||
|
maxMillis := s.catalog.MaxStaminaMillis[userLevel]
|
||||||
|
return effectValue * maxMillis / 1000
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
var startedGameStartTables = []string{
|
||||||
|
"IUserProfile",
|
||||||
|
"IUserCharacter",
|
||||||
|
"IUserCostume",
|
||||||
|
"IUserWeapon",
|
||||||
|
"IUserWeaponSkill",
|
||||||
|
"IUserWeaponAbility",
|
||||||
|
"IUserCompanion",
|
||||||
|
"IUserDeckCharacter",
|
||||||
|
"IUserDeck",
|
||||||
|
"IUserGem",
|
||||||
|
"IUserMission",
|
||||||
|
"IUserMainQuestFlowStatus",
|
||||||
|
"IUserMainQuestMainFlowStatus",
|
||||||
|
"IUserMainQuestProgressStatus",
|
||||||
|
"IUserMainQuestSeasonRoute",
|
||||||
|
"IUserQuest",
|
||||||
|
"IUserQuestMission",
|
||||||
|
"IUserTutorialProgress",
|
||||||
|
"IUserWeaponNote",
|
||||||
|
"IUserWeaponStory",
|
||||||
|
"IUserCostumeActiveSkill",
|
||||||
|
"IUserDeckTypeNote",
|
||||||
|
"IUserDeckSubWeaponGroup",
|
||||||
|
"IUserDeckPartsGroup",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserImportantItem",
|
||||||
|
}
|
||||||
|
|
||||||
|
var gimmickDiffTables = []string{
|
||||||
|
"IUserGimmick",
|
||||||
|
"IUserGimmickOrnamentProgress",
|
||||||
|
"IUserGimmickSequence",
|
||||||
|
"IUserGimmickUnlock",
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentUserId(ctx context.Context, users store.UserRepository, sessions store.SessionRepository) int64 {
|
||||||
|
if md, ok := metadata.FromIncomingContext(ctx); ok {
|
||||||
|
if vals := md.Get("x-session-key"); len(vals) > 0 {
|
||||||
|
if userId, err := sessions.ResolveUserId(vals[0]); err == nil {
|
||||||
|
return userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultId, _ := users.DefaultUserId()
|
||||||
|
return defaultId
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/questflow"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TutorialServiceServer struct {
|
||||||
|
pb.UnimplementedTutorialServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
engine *questflow.QuestHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTutorialServiceServer(users store.UserRepository, sessions store.SessionRepository, engine *questflow.QuestHandler) *TutorialServiceServer {
|
||||||
|
return &TutorialServiceServer{users: users, sessions: sessions, engine: engine}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TutorialServiceServer) SetTutorialProgress(ctx context.Context, req *pb.SetTutorialProgressRequest) (*pb.SetTutorialProgressResponse, error) {
|
||||||
|
log.Printf("[TutorialService] SetTutorialProgress: type=%d phase=%d choice=%d", req.TutorialType, req.ProgressPhase, req.ChoiceId)
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
var grants []questflow.RewardGrant
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.Tutorials[req.TutorialType] = store.TutorialProgressState{
|
||||||
|
TutorialType: req.TutorialType,
|
||||||
|
ProgressPhase: req.ProgressPhase,
|
||||||
|
ChoiceId: req.ChoiceId,
|
||||||
|
}
|
||||||
|
if req.TutorialType == int32(model.TutorialTypeMenuFirst) ||
|
||||||
|
req.TutorialType == int32(model.TutorialTypeMenuSecond) {
|
||||||
|
store.EnsureDefaultDeck(user, nowMillis)
|
||||||
|
}
|
||||||
|
grants = s.engine.ApplyTutorialReward(user, model.TutorialType(req.TutorialType), req.ChoiceId, nowMillis)
|
||||||
|
})
|
||||||
|
tables := []string{"IUserTutorialProgress"}
|
||||||
|
if req.TutorialType == int32(model.TutorialTypeMenuFirst) ||
|
||||||
|
req.TutorialType == int32(model.TutorialTypeMenuSecond) {
|
||||||
|
tables = append(tables,
|
||||||
|
"IUserCharacter", "IUserCostume", "IUserWeapon",
|
||||||
|
"IUserWeaponSkill", "IUserWeaponAbility",
|
||||||
|
"IUserCompanion", "IUserDeckCharacter", "IUserDeck",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if len(grants) > 0 {
|
||||||
|
tables = append(tables, "IUserCompanion")
|
||||||
|
}
|
||||||
|
result := userdata.SelectTables(userdata.FullClientTableMap(user), tables)
|
||||||
|
for _, t := range tables {
|
||||||
|
log.Printf("[TutorialService] DiffTable %s -> %s", t, result[t])
|
||||||
|
}
|
||||||
|
rewards := make([]*pb.TutorialChoiceReward, len(grants))
|
||||||
|
for i, g := range grants {
|
||||||
|
rewards[i] = &pb.TutorialChoiceReward{
|
||||||
|
PossessionType: int32(g.PossessionType),
|
||||||
|
PossessionId: g.PossessionId,
|
||||||
|
Count: g.Count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &pb.SetTutorialProgressResponse{
|
||||||
|
TutorialChoiceReward: rewards,
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(result),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TutorialServiceServer) SetTutorialProgressAndReplaceDeck(ctx context.Context, req *pb.SetTutorialProgressAndReplaceDeckRequest) (*pb.SetTutorialProgressAndReplaceDeckResponse, error) {
|
||||||
|
log.Printf("[TutorialService] SetTutorialProgressAndReplaceDeck: type=%d phase=%d deckType=%d deckNumber=%d", req.TutorialType, req.ProgressPhase, req.DeckType, req.UserDeckNumber)
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.Tutorials[req.TutorialType] = store.TutorialProgressState{
|
||||||
|
TutorialType: req.TutorialType,
|
||||||
|
ProgressPhase: req.ProgressPhase,
|
||||||
|
}
|
||||||
|
if req.Deck != nil {
|
||||||
|
store.ApplyDeckReplacement(user, model.DeckType(req.DeckType), req.UserDeckNumber, deckSlotsFromProto(req.Deck), gametime.NowMillis())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return &pb.SetTutorialProgressAndReplaceDeckResponse{
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{
|
||||||
|
"IUserTutorialProgress",
|
||||||
|
"IUserDeck",
|
||||||
|
"IUserDeckCharacter",
|
||||||
|
"IUserDeckSubWeaponGroup",
|
||||||
|
})),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserServiceServer struct {
|
||||||
|
pb.UnimplementedUserServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserServiceServer(users store.UserRepository, sessions store.SessionRepository) *UserServiceServer {
|
||||||
|
return &UserServiceServer{users: users, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCommonResponseTrailers(ctx context.Context, diff map[string]*pb.DiffData, includeUpdateNames bool) {
|
||||||
|
keys := make([]string, 0, len(diff))
|
||||||
|
for key := range diff {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
var pairs []string
|
||||||
|
if includeUpdateNames && len(keys) > 0 {
|
||||||
|
pairs = append(pairs, "x-apb-update-user-data-names", keys[0])
|
||||||
|
for _, key := range keys[1:] {
|
||||||
|
pairs[len(pairs)-1] += "," + key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := grpc.SetTrailer(ctx, metadata.Pairs(pairs...)); err != nil {
|
||||||
|
log.Printf("[UserService] failed to set trailers: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) RegisterUser(ctx context.Context, req *pb.RegisterUserRequest) (*pb.RegisterUserResponse, error) {
|
||||||
|
user, err := s.users.EnsureUser(req.Uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure user: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("[UserService] RegisterUser: uuid=%s terminalId=%s -> userId=%d", req.Uuid, req.TerminalId, user.UserId)
|
||||||
|
|
||||||
|
return &pb.RegisterUserResponse{
|
||||||
|
UserId: user.UserId,
|
||||||
|
Signature: fmt.Sprintf("sig_%d_%d", user.UserId, gametime.Now().Unix()),
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(userdata.FirstEntranceClientTableMap(user)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) Auth(ctx context.Context, req *pb.AuthUserRequest) (*pb.AuthUserResponse, error) {
|
||||||
|
log.Printf("[UserService] Auth: uuid=%s", req.Uuid)
|
||||||
|
|
||||||
|
user, session, err := s.sessions.CreateSession(req.Uuid, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.AuthUserResponse{
|
||||||
|
SessionKey: session.SessionKey,
|
||||||
|
ExpireDatetime: timestamppb.New(session.ExpireAt),
|
||||||
|
Signature: req.Signature,
|
||||||
|
UserId: user.UserId,
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(userdata.FirstEntranceClientTableMap(user)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) GameStart(ctx context.Context, _ *emptypb.Empty) (*pb.GameStartResponse, error) {
|
||||||
|
log.Printf("[UserService] GameStart")
|
||||||
|
|
||||||
|
if md, ok := metadata.FromIncomingContext(ctx); ok {
|
||||||
|
if vals := md.Get("x-session-key"); len(vals) > 0 {
|
||||||
|
log.Printf("[UserService] GameStart session: %s", vals[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.GameStartDatetime = gametime.NowMillis()
|
||||||
|
})
|
||||||
|
fullTables := userdata.FullClientTableMap(user)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(fullTables, startedGameStartTables))
|
||||||
|
setCommonResponseTrailers(ctx, diff, true)
|
||||||
|
|
||||||
|
return &pb.GameStartResponse{
|
||||||
|
// Apply only the starter outgame rows we need after title completion.
|
||||||
|
// Keep IUser and other risky core-account rows out of GameStart diff.
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) TransferUser(ctx context.Context, req *pb.TransferUserRequest) (*pb.TransferUserResponse, error) {
|
||||||
|
log.Printf("[UserService] TransferUser")
|
||||||
|
user, err := s.users.EnsureUser(req.Uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure user: %w", err)
|
||||||
|
}
|
||||||
|
return &pb.TransferUserResponse{
|
||||||
|
UserId: user.UserId,
|
||||||
|
Signature: "transferred-sig",
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) SetUserName(ctx context.Context, req *pb.SetUserNameRequest) (*pb.SetUserNameResponse, error) {
|
||||||
|
log.Printf("[UserService] SetUserName: %s", req.Name)
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
user.Profile.Name = req.Name
|
||||||
|
user.Profile.NameUpdateDatetime = nowMillis
|
||||||
|
})
|
||||||
|
return &pb.SetUserNameResponse{
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) SetUserMessage(ctx context.Context, req *pb.SetUserMessageRequest) (*pb.SetUserMessageResponse, error) {
|
||||||
|
log.Printf("[UserService] SetUserMessage: %s", req.Message)
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
user.Profile.Message = req.Message
|
||||||
|
user.Profile.MessageUpdateDatetime = nowMillis
|
||||||
|
})
|
||||||
|
return &pb.SetUserMessageResponse{
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) SetUserFavoriteCostumeId(ctx context.Context, req *pb.SetUserFavoriteCostumeIdRequest) (*pb.SetUserFavoriteCostumeIdResponse, error) {
|
||||||
|
log.Printf("[UserService] SetUserFavoriteCostumeId: %d", req.FavoriteCostumeId)
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
user.Profile.FavoriteCostumeId = req.FavoriteCostumeId
|
||||||
|
user.Profile.FavoriteCostumeIdUpdateDatetime = nowMillis
|
||||||
|
})
|
||||||
|
return &pb.SetUserFavoriteCostumeIdResponse{
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) GetUserProfile(ctx context.Context, req *pb.GetUserProfileRequest) (*pb.GetUserProfileResponse, error) {
|
||||||
|
log.Printf("[UserService] GetUserProfile: playerId=%d", req.PlayerId)
|
||||||
|
userId := req.PlayerId
|
||||||
|
if userId == 0 {
|
||||||
|
userId = currentUserId(ctx, s.users, s.sessions)
|
||||||
|
}
|
||||||
|
user, err := s.users.SnapshotUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return &pb.GetUserProfileResponse{DiffUserData: userdata.EmptyDiff()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deckCharacters := []*pb.ProfileDeckCharacter{}
|
||||||
|
if deck, ok := user.Decks[store.DeckKey{DeckType: model.DeckTypeQuest, UserDeckNumber: 1}]; ok && deck.UserDeckCharacterUuid01 != "" {
|
||||||
|
if deckCharacter, ok := user.DeckCharacters[deck.UserDeckCharacterUuid01]; ok {
|
||||||
|
costumeId := int32(0)
|
||||||
|
if costume, ok := user.Costumes[deckCharacter.UserCostumeUuid]; ok {
|
||||||
|
costumeId = costume.CostumeId
|
||||||
|
}
|
||||||
|
mainWeaponId := int32(0)
|
||||||
|
mainWeaponLevel := int32(0)
|
||||||
|
if weapon, ok := user.Weapons[deckCharacter.MainUserWeaponUuid]; ok {
|
||||||
|
mainWeaponId = weapon.WeaponId
|
||||||
|
mainWeaponLevel = weapon.Level
|
||||||
|
}
|
||||||
|
deckCharacters = append(deckCharacters, &pb.ProfileDeckCharacter{
|
||||||
|
CostumeId: costumeId,
|
||||||
|
MainWeaponId: mainWeaponId,
|
||||||
|
MainWeaponLevel: mainWeaponLevel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.GetUserProfileResponse{
|
||||||
|
Level: user.Status.Level,
|
||||||
|
Name: user.Profile.Name,
|
||||||
|
FavoriteCostumeId: user.Profile.FavoriteCostumeId,
|
||||||
|
Message: user.Profile.Message,
|
||||||
|
IsFriend: false,
|
||||||
|
LatestUsedDeck: &pb.ProfileDeck{
|
||||||
|
Power: 100,
|
||||||
|
DeckCharacter: deckCharacters,
|
||||||
|
},
|
||||||
|
PvpInfo: &pb.ProfilePvpInfo{},
|
||||||
|
GamePlayHistory: &pb.GamePlayHistory{
|
||||||
|
HistoryItem: []*pb.PlayHistoryItem{},
|
||||||
|
HistoryCategoryGraphItem: []*pb.PlayHistoryCategoryGraphItem{},
|
||||||
|
},
|
||||||
|
DiffUserData: userdata.EmptyDiff(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) SetBirthYearMonth(ctx context.Context, req *pb.SetBirthYearMonthRequest) (*pb.SetBirthYearMonthResponse, error) {
|
||||||
|
log.Printf("[UserService] SetBirthYearMonth: %d/%d", req.BirthYear, req.BirthMonth)
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
_, _ = s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.BirthYear = req.BirthYear
|
||||||
|
user.BirthMonth = req.BirthMonth
|
||||||
|
})
|
||||||
|
return &pb.SetBirthYearMonthResponse{DiffUserData: userdata.EmptyDiff()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) GetBirthYearMonth(ctx context.Context, _ *emptypb.Empty) (*pb.GetBirthYearMonthResponse, error) {
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, err := s.users.SnapshotUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return &pb.GetBirthYearMonthResponse{BirthYear: 2000, BirthMonth: 1, DiffUserData: userdata.EmptyDiff()}, nil
|
||||||
|
}
|
||||||
|
return &pb.GetBirthYearMonthResponse{BirthYear: user.BirthYear, BirthMonth: user.BirthMonth, DiffUserData: userdata.EmptyDiff()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) GetChargeMoney(ctx context.Context, _ *emptypb.Empty) (*pb.GetChargeMoneyResponse, error) {
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, err := s.users.SnapshotUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return &pb.GetChargeMoneyResponse{ChargeMoneyThisMonth: 0, DiffUserData: userdata.EmptyDiff()}, nil
|
||||||
|
}
|
||||||
|
return &pb.GetChargeMoneyResponse{ChargeMoneyThisMonth: user.ChargeMoneyThisMonth, DiffUserData: userdata.EmptyDiff()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) SetUserSetting(ctx context.Context, req *pb.SetUserSettingRequest) (*pb.SetUserSettingResponse, error) {
|
||||||
|
log.Printf("[UserService] SetUserSetting: isNotifyPurchaseAlert=%v", req.IsNotifyPurchaseAlert)
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
user.Setting.IsNotifyPurchaseAlert = req.IsNotifyPurchaseAlert
|
||||||
|
})
|
||||||
|
return &pb.SetUserSettingResponse{
|
||||||
|
DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserSetting"})),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) GetAndroidArgs(ctx context.Context, req *pb.GetAndroidArgsRequest) (*pb.GetAndroidArgsResponse, error) {
|
||||||
|
return &pb.GetAndroidArgsResponse{Nonce: "Mama", ApiKey: "1234567890", DiffUserData: userdata.EmptyDiff()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) GetBackupToken(ctx context.Context, req *pb.GetBackupTokenRequest) (*pb.GetBackupTokenResponse, error) {
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
user, err := s.users.SnapshotUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return &pb.GetBackupTokenResponse{BackupToken: "mock-backup-token", DiffUserData: userdata.EmptyDiff()}, nil
|
||||||
|
}
|
||||||
|
return &pb.GetBackupTokenResponse{BackupToken: user.BackupToken, DiffUserData: userdata.EmptyDiff()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) CheckTransferSetting(ctx context.Context, _ *emptypb.Empty) (*pb.CheckTransferSettingResponse, error) {
|
||||||
|
return &pb.CheckTransferSettingResponse{DiffUserData: userdata.EmptyDiff()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceServer) GetUserGamePlayNote(ctx context.Context, req *pb.GetUserGamePlayNoteRequest) (*pb.GetUserGamePlayNoteResponse, error) {
|
||||||
|
return &pb.GetUserGamePlayNoteResponse{DiffUserData: userdata.EmptyDiff()}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,760 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pb "lunar-tear/server/gen/proto"
|
||||||
|
"lunar-tear/server/internal/gametime"
|
||||||
|
"lunar-tear/server/internal/gameutil"
|
||||||
|
"lunar-tear/server/internal/masterdata"
|
||||||
|
"lunar-tear/server/internal/model"
|
||||||
|
"lunar-tear/server/internal/store"
|
||||||
|
"lunar-tear/server/internal/userdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
var weaponDiffTables = []string{
|
||||||
|
"IUserWeapon",
|
||||||
|
"IUserWeaponSkill",
|
||||||
|
"IUserWeaponAbility",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserWeaponStory",
|
||||||
|
}
|
||||||
|
|
||||||
|
var limitBreakDiffTables = []string{
|
||||||
|
"IUserWeapon",
|
||||||
|
"IUserWeaponSkill",
|
||||||
|
"IUserWeaponAbility",
|
||||||
|
"IUserMaterial",
|
||||||
|
"IUserConsumableItem",
|
||||||
|
"IUserWeaponNote",
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeaponServiceServer struct {
|
||||||
|
pb.UnimplementedWeaponServiceServer
|
||||||
|
users store.UserRepository
|
||||||
|
sessions store.SessionRepository
|
||||||
|
catalog *masterdata.WeaponCatalog
|
||||||
|
config *masterdata.GameConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWeaponServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.WeaponCatalog, config *masterdata.GameConfig) *WeaponServiceServer {
|
||||||
|
return &WeaponServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeaponServiceServer) Protect(ctx context.Context, req *pb.ProtectRequest) (*pb.ProtectResponse, error) {
|
||||||
|
log.Printf("[WeaponService] Protect: uuids=%v", req.UserWeaponUuid)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
for _, uuid := range req.UserWeaponUuid {
|
||||||
|
weapon, ok := user.Weapons[uuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] Protect: weapon uuid=%s not found", uuid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
weapon.IsProtected = true
|
||||||
|
weapon.LatestVersion = nowMillis
|
||||||
|
user.Weapons[uuid] = weapon
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserWeapon"}))
|
||||||
|
return &pb.ProtectResponse{DiffUserData: diff}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeaponServiceServer) Unprotect(ctx context.Context, req *pb.UnprotectRequest) (*pb.UnprotectResponse, error) {
|
||||||
|
log.Printf("[WeaponService] Unprotect: uuids=%v", req.UserWeaponUuid)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, _ := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
for _, uuid := range req.UserWeaponUuid {
|
||||||
|
weapon, ok := user.Weapons[uuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] Unprotect: weapon uuid=%s not found", uuid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
weapon.IsProtected = false
|
||||||
|
weapon.LatestVersion = nowMillis
|
||||||
|
user.Weapons[uuid] = weapon
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserWeapon"}))
|
||||||
|
return &pb.UnprotectResponse{DiffUserData: diff}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.EnhanceByMaterialRequest) (*pb.EnhanceByMaterialResponse, error) {
|
||||||
|
log.Printf("[WeaponService] EnhanceByMaterial: uuid=%s materials=%v", req.UserWeaponUuid, req.Materials)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
weapon, ok := user.Weapons[req.UserWeaponUuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] EnhanceByMaterial: weapon uuid=%s not found", req.UserWeaponUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wm, ok := s.catalog.Weapons[weapon.WeaponId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] EnhanceByMaterial: weapon master id=%d not found", weapon.WeaponId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalExp := int32(0)
|
||||||
|
totalMaterialCount := int32(0)
|
||||||
|
for materialId, count := range req.Materials {
|
||||||
|
mat, ok := s.catalog.Materials[materialId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] EnhanceByMaterial: material id=%d not found, skipping", materialId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cur := user.Materials[materialId]
|
||||||
|
if cur < count {
|
||||||
|
log.Printf("[WeaponService] EnhanceByMaterial: insufficient material id=%d have=%d need=%d", materialId, cur, count)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
user.Materials[materialId] = cur - count
|
||||||
|
totalMaterialCount += count
|
||||||
|
|
||||||
|
expPerUnit := mat.EffectValue
|
||||||
|
if mat.WeaponType != 0 && mat.WeaponType == wm.WeaponType {
|
||||||
|
expPerUnit = expPerUnit * s.config.MaterialSameWeaponExpCoefficientPermil / 1000
|
||||||
|
}
|
||||||
|
totalExp += expPerUnit * count
|
||||||
|
}
|
||||||
|
|
||||||
|
if costFunc, ok := s.catalog.GoldCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 {
|
||||||
|
goldCost := costFunc.Evaluate(totalMaterialCount)
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
|
||||||
|
log.Printf("[WeaponService] EnhanceByMaterial: gold cost=%d (materials=%d)", goldCost, totalMaterialCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
weapon.Exp += totalExp
|
||||||
|
if thresholds, ok := s.catalog.ExpByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
|
||||||
|
weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds)
|
||||||
|
}
|
||||||
|
|
||||||
|
weapon.LatestVersion = nowMillis
|
||||||
|
user.Weapons[req.UserWeaponUuid] = weapon
|
||||||
|
log.Printf("[WeaponService] EnhanceByMaterial: weaponId=%d +%d exp -> total=%d level=%d", weapon.WeaponId, totalExp, weapon.Exp, weapon.Level)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("weapon enhance by material: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables))
|
||||||
|
|
||||||
|
return &pb.EnhanceByMaterialResponse{
|
||||||
|
IsGreatSuccess: false,
|
||||||
|
SurplusEnhanceMaterial: map[int32]int32{},
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeaponServiceServer) Sell(ctx context.Context, req *pb.SellRequest) (*pb.SellResponse, error) {
|
||||||
|
log.Printf("[WeaponService] Sell: uuids=%v", req.UserWeaponUuid)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
|
||||||
|
oldUser, _ := s.users.SnapshotUser(userId)
|
||||||
|
tracker := userdata.NewDeleteTracker().
|
||||||
|
Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}).
|
||||||
|
Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}).
|
||||||
|
Track("IUserWeaponAbility", oldUser, userdata.SortedWeaponAbilityRecords, []string{"userId", "userWeaponUuid", "slotNumber"})
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
totalGold := int32(0)
|
||||||
|
for _, uuid := range req.UserWeaponUuid {
|
||||||
|
weapon, ok := user.Weapons[uuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] Sell: weapon uuid=%s not found, skipping", uuid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wm, ok := s.catalog.Weapons[weapon.WeaponId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] Sell: weapon master id=%d not found, skipping", weapon.WeaponId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if sellFunc, ok := s.catalog.SellPriceByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
|
||||||
|
totalGold += sellFunc.Evaluate(weapon.Level)
|
||||||
|
}
|
||||||
|
|
||||||
|
if medals, ok := s.catalog.MedalsByWeaponId[weapon.WeaponId]; ok {
|
||||||
|
for itemId, count := range medals {
|
||||||
|
user.ConsumableItems[itemId] += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(user.Weapons, uuid)
|
||||||
|
delete(user.WeaponSkills, uuid)
|
||||||
|
delete(user.WeaponAbilities, uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalGold > 0 {
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold
|
||||||
|
log.Printf("[WeaponService] Sell: granted %d gold", totalGold)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("weapon sell: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sellDiffTables := []string{"IUserWeapon", "IUserWeaponSkill", "IUserWeaponAbility", "IUserConsumableItem"}
|
||||||
|
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), sellDiffTables)
|
||||||
|
diff := tracker.Apply(snapshot, tables)
|
||||||
|
|
||||||
|
return &pb.SellResponse{DiffUserData: diff}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeaponServiceServer) Evolve(ctx context.Context, req *pb.EvolveRequest) (*pb.EvolveResponse, error) {
|
||||||
|
log.Printf("[WeaponService] Evolve: uuid=%s", req.UserWeaponUuid)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
weapon, ok := user.Weapons[req.UserWeaponUuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] Evolve: weapon uuid=%s not found", req.UserWeaponUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wm, ok := s.catalog.Weapons[weapon.WeaponId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] Evolve: weapon master id=%d not found", weapon.WeaponId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
evolvedId, ok := s.catalog.EvolutionNextWeaponId[weapon.WeaponId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] Evolve: no evolution for weaponId=%d", weapon.WeaponId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalMaterialCount := int32(0)
|
||||||
|
mats := s.catalog.EvolutionMaterials[wm.WeaponEvolutionMaterialGroupId]
|
||||||
|
for _, mat := range mats {
|
||||||
|
cur := user.Materials[mat.MaterialId]
|
||||||
|
cost := mat.Count
|
||||||
|
if cur < cost {
|
||||||
|
log.Printf("[WeaponService] Evolve: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost)
|
||||||
|
cost = cur
|
||||||
|
}
|
||||||
|
user.Materials[mat.MaterialId] = cur - cost
|
||||||
|
totalMaterialCount += cost
|
||||||
|
}
|
||||||
|
|
||||||
|
if costFunc, ok := s.catalog.EvolutionCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 {
|
||||||
|
goldCost := costFunc.Evaluate(totalMaterialCount)
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
|
||||||
|
log.Printf("[WeaponService] Evolve: gold cost=%d", goldCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
weapon.WeaponId = evolvedId
|
||||||
|
weapon.LatestVersion = nowMillis
|
||||||
|
user.Weapons[req.UserWeaponUuid] = weapon
|
||||||
|
|
||||||
|
evolvedMaster, ok := s.catalog.Weapons[evolvedId]
|
||||||
|
if ok {
|
||||||
|
if slots, ok := s.catalog.AbilitySlots[evolvedMaster.WeaponAbilityGroupId]; ok {
|
||||||
|
abilities := make([]store.WeaponAbilityState, len(slots))
|
||||||
|
for i, slot := range slots {
|
||||||
|
abilities[i] = store.WeaponAbilityState{
|
||||||
|
UserWeaponUuid: req.UserWeaponUuid,
|
||||||
|
SlotNumber: slot,
|
||||||
|
Level: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.WeaponAbilities[req.UserWeaponUuid] = abilities
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[WeaponService] Evolve: weaponId %d -> %d", wm.WeaponId, evolvedId)
|
||||||
|
|
||||||
|
s.checkEvolutionStoryUnlocks(user, evolvedId, nowMillis)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("weapon evolve: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables))
|
||||||
|
|
||||||
|
return &pb.EvolveResponse{DiffUserData: diff}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceSkillRequest) (*pb.EnhanceSkillResponse, error) {
|
||||||
|
log.Printf("[WeaponService] EnhanceSkill: uuid=%s skillId=%d addLevel=%d", req.UserWeaponUuid, req.SkillId, req.AddLevelCount)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
weapon, ok := user.Weapons[req.UserWeaponUuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] EnhanceSkill: weapon uuid=%s not found", req.UserWeaponUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wm, ok := s.catalog.Weapons[weapon.WeaponId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] EnhanceSkill: weapon master id=%d not found", weapon.WeaponId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupRows := s.catalog.SkillGroupsByGroupId[wm.WeaponSkillGroupId]
|
||||||
|
var skillGroup *masterdata.WeaponSkillGroupRow
|
||||||
|
for i := range groupRows {
|
||||||
|
if groupRows[i].SkillId == req.SkillId {
|
||||||
|
skillGroup = &groupRows[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skillGroup == nil {
|
||||||
|
log.Printf("[WeaponService] EnhanceSkill: skillId=%d not found in group=%d", req.SkillId, wm.WeaponSkillGroupId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
skills := user.WeaponSkills[req.UserWeaponUuid]
|
||||||
|
skillIdx := -1
|
||||||
|
for i, sk := range skills {
|
||||||
|
if sk.SlotNumber == skillGroup.SlotNumber {
|
||||||
|
skillIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skillIdx < 0 {
|
||||||
|
log.Printf("[WeaponService] EnhanceSkill: slot=%d not found for weapon uuid=%s", skillGroup.SlotNumber, req.UserWeaponUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxLevelFunc, ok := s.catalog.SkillMaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] EnhanceSkill: no max skill level func for enhanceId=%d", wm.WeaponSpecificEnhanceId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
maxLevel := maxLevelFunc.Evaluate(weapon.LimitBreakCount)
|
||||||
|
|
||||||
|
currentLevel := skills[skillIdx].Level
|
||||||
|
addCount := req.AddLevelCount
|
||||||
|
if currentLevel+addCount > maxLevel {
|
||||||
|
addCount = maxLevel - currentLevel
|
||||||
|
}
|
||||||
|
if addCount <= 0 {
|
||||||
|
log.Printf("[WeaponService] EnhanceSkill: already at max level %d", currentLevel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enhanceMatId := skillGroup.WeaponSkillEnhancementMaterialId
|
||||||
|
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
|
||||||
|
key := [2]int32{enhanceMatId, lvl}
|
||||||
|
mats := s.catalog.SkillEnhanceMats[key]
|
||||||
|
for _, mat := range mats {
|
||||||
|
cur := user.Materials[mat.MaterialId]
|
||||||
|
cost := mat.Count
|
||||||
|
if cur < cost {
|
||||||
|
log.Printf("[WeaponService] EnhanceSkill: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost)
|
||||||
|
cost = cur
|
||||||
|
}
|
||||||
|
user.Materials[mat.MaterialId] = cur - cost
|
||||||
|
}
|
||||||
|
|
||||||
|
if costFunc, ok := s.catalog.SkillCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
|
||||||
|
goldCost := costFunc.Evaluate(lvl + 1)
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skills[skillIdx].Level = currentLevel + addCount
|
||||||
|
user.WeaponSkills[req.UserWeaponUuid] = skills
|
||||||
|
log.Printf("[WeaponService] EnhanceSkill: skillId=%d level %d -> %d", req.SkillId, currentLevel, skills[skillIdx].Level)
|
||||||
|
|
||||||
|
weapon.LatestVersion = nowMillis
|
||||||
|
user.Weapons[req.UserWeaponUuid] = weapon
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("weapon enhance skill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables))
|
||||||
|
|
||||||
|
return &pb.EnhanceSkillResponse{DiffUserData: diff}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.EnhanceAbilityRequest) (*pb.EnhanceAbilityResponse, error) {
|
||||||
|
log.Printf("[WeaponService] EnhanceAbility: uuid=%s abilityId=%d addLevel=%d", req.UserWeaponUuid, req.AbilityId, req.AddLevelCount)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
weapon, ok := user.Weapons[req.UserWeaponUuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] EnhanceAbility: weapon uuid=%s not found", req.UserWeaponUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wm, ok := s.catalog.Weapons[weapon.WeaponId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] EnhanceAbility: weapon master id=%d not found", weapon.WeaponId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupRows := s.catalog.AbilityGroupsByGroupId[wm.WeaponAbilityGroupId]
|
||||||
|
var abilityGroup *masterdata.WeaponAbilityGroupRow
|
||||||
|
for i := range groupRows {
|
||||||
|
if groupRows[i].AbilityId == req.AbilityId {
|
||||||
|
abilityGroup = &groupRows[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if abilityGroup == nil {
|
||||||
|
log.Printf("[WeaponService] EnhanceAbility: abilityId=%d not found in group=%d", req.AbilityId, wm.WeaponAbilityGroupId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
abilities := user.WeaponAbilities[req.UserWeaponUuid]
|
||||||
|
abilityIdx := -1
|
||||||
|
for i, ab := range abilities {
|
||||||
|
if ab.SlotNumber == abilityGroup.SlotNumber {
|
||||||
|
abilityIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if abilityIdx < 0 {
|
||||||
|
log.Printf("[WeaponService] EnhanceAbility: slot=%d not found for weapon uuid=%s", abilityGroup.SlotNumber, req.UserWeaponUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxLevelFunc, ok := s.catalog.AbilityMaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] EnhanceAbility: no max ability level func for enhanceId=%d", wm.WeaponSpecificEnhanceId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
maxLevel := maxLevelFunc.Evaluate(weapon.LimitBreakCount)
|
||||||
|
|
||||||
|
currentLevel := abilities[abilityIdx].Level
|
||||||
|
addCount := req.AddLevelCount
|
||||||
|
if currentLevel+addCount > maxLevel {
|
||||||
|
addCount = maxLevel - currentLevel
|
||||||
|
}
|
||||||
|
if addCount <= 0 {
|
||||||
|
log.Printf("[WeaponService] EnhanceAbility: already at max level %d", currentLevel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enhanceMatId := abilityGroup.WeaponAbilityEnhancementMaterialId
|
||||||
|
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
|
||||||
|
key := [2]int32{enhanceMatId, lvl}
|
||||||
|
mats := s.catalog.AbilityEnhanceMats[key]
|
||||||
|
for _, mat := range mats {
|
||||||
|
cur := user.Materials[mat.MaterialId]
|
||||||
|
cost := mat.Count
|
||||||
|
if cur < cost {
|
||||||
|
log.Printf("[WeaponService] EnhanceAbility: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost)
|
||||||
|
cost = cur
|
||||||
|
}
|
||||||
|
user.Materials[mat.MaterialId] = cur - cost
|
||||||
|
}
|
||||||
|
|
||||||
|
if costFunc, ok := s.catalog.AbilityCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
|
||||||
|
goldCost := costFunc.Evaluate(lvl + 1)
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abilities[abilityIdx].Level = currentLevel + addCount
|
||||||
|
user.WeaponAbilities[req.UserWeaponUuid] = abilities
|
||||||
|
log.Printf("[WeaponService] EnhanceAbility: abilityId=%d level %d -> %d", req.AbilityId, currentLevel, abilities[abilityIdx].Level)
|
||||||
|
|
||||||
|
weapon.LatestVersion = nowMillis
|
||||||
|
user.Weapons[req.UserWeaponUuid] = weapon
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("weapon enhance ability: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables))
|
||||||
|
|
||||||
|
return &pb.EnhanceAbilityResponse{DiffUserData: diff}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeaponServiceServer) LimitBreakByMaterial(ctx context.Context, req *pb.LimitBreakByMaterialRequest) (*pb.LimitBreakByMaterialResponse, error) {
|
||||||
|
log.Printf("[WeaponService] LimitBreakByMaterial: uuid=%s materials=%v", req.UserWeaponUuid, req.Materials)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
weapon, ok := user.Weapons[req.UserWeaponUuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] LimitBreakByMaterial: weapon uuid=%s not found", req.UserWeaponUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if weapon.LimitBreakCount >= s.config.WeaponLimitBreakAvailableCount {
|
||||||
|
log.Printf("[WeaponService] LimitBreakByMaterial: already at max limit break %d", weapon.LimitBreakCount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wm, ok := s.catalog.Weapons[weapon.WeaponId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] LimitBreakByMaterial: weapon master id=%d not found", weapon.WeaponId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := s.config.WeaponLimitBreakAvailableCount - weapon.LimitBreakCount
|
||||||
|
|
||||||
|
totalMaterialCount := int32(0)
|
||||||
|
for materialId, count := range req.Materials {
|
||||||
|
if totalMaterialCount >= remaining {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if count > remaining-totalMaterialCount {
|
||||||
|
count = remaining - totalMaterialCount
|
||||||
|
}
|
||||||
|
cur := user.Materials[materialId]
|
||||||
|
if cur < count {
|
||||||
|
log.Printf("[WeaponService] LimitBreakByMaterial: insufficient material id=%d have=%d need=%d", materialId, cur, count)
|
||||||
|
count = cur
|
||||||
|
}
|
||||||
|
user.Materials[materialId] = cur - count
|
||||||
|
totalMaterialCount += count
|
||||||
|
}
|
||||||
|
|
||||||
|
if costFunc, ok := s.catalog.LimitBreakCostByMaterialByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 {
|
||||||
|
goldCost := costFunc.Evaluate(totalMaterialCount)
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
|
||||||
|
log.Printf("[WeaponService] LimitBreakByMaterial: gold cost=%d", goldCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
weapon.LimitBreakCount += totalMaterialCount
|
||||||
|
weapon.LatestVersion = nowMillis
|
||||||
|
user.Weapons[req.UserWeaponUuid] = weapon
|
||||||
|
|
||||||
|
note := user.WeaponNotes[weapon.WeaponId]
|
||||||
|
if note.MaxLimitBreakCount < weapon.LimitBreakCount {
|
||||||
|
note.MaxLimitBreakCount = weapon.LimitBreakCount
|
||||||
|
note.LatestVersion = nowMillis
|
||||||
|
user.WeaponNotes[weapon.WeaponId] = note
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[WeaponService] LimitBreakByMaterial: weaponId=%d limitBreak -> %d", weapon.WeaponId, weapon.LimitBreakCount)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("weapon limit break by material: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.FullClientTableMap(snapshot)
|
||||||
|
diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, limitBreakDiffTables))
|
||||||
|
|
||||||
|
return &pb.LimitBreakByMaterialResponse{DiffUserData: diff}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.LimitBreakByWeaponRequest) (*pb.LimitBreakByWeaponResponse, error) {
|
||||||
|
log.Printf("[WeaponService] LimitBreakByWeapon: uuid=%s materialUuids=%v", req.UserWeaponUuid, req.MaterialUserWeaponUuids)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
oldUser, _ := s.users.SnapshotUser(userId)
|
||||||
|
tracker := userdata.NewDeleteTracker().
|
||||||
|
Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}).
|
||||||
|
Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}).
|
||||||
|
Track("IUserWeaponAbility", oldUser, userdata.SortedWeaponAbilityRecords, []string{"userId", "userWeaponUuid", "slotNumber"})
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
weapon, ok := user.Weapons[req.UserWeaponUuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] LimitBreakByWeapon: weapon uuid=%s not found", req.UserWeaponUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if weapon.LimitBreakCount >= s.config.WeaponLimitBreakAvailableCount {
|
||||||
|
log.Printf("[WeaponService] LimitBreakByWeapon: already at max limit break %d", weapon.LimitBreakCount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wm, ok := s.catalog.Weapons[weapon.WeaponId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] LimitBreakByWeapon: weapon master id=%d not found", weapon.WeaponId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := s.config.WeaponLimitBreakAvailableCount - weapon.LimitBreakCount
|
||||||
|
|
||||||
|
consumedCount := int32(0)
|
||||||
|
for _, uuid := range req.MaterialUserWeaponUuids {
|
||||||
|
if consumedCount >= remaining {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
matWeapon, ok := user.Weapons[uuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] LimitBreakByWeapon: material weapon uuid=%s not found, skipping", uuid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if medals, ok := s.catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
|
||||||
|
for itemId, count := range medals {
|
||||||
|
user.ConsumableItems[itemId] += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(user.Weapons, uuid)
|
||||||
|
delete(user.WeaponSkills, uuid)
|
||||||
|
delete(user.WeaponAbilities, uuid)
|
||||||
|
consumedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if costFunc, ok := s.catalog.LimitBreakCostByWeaponByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && consumedCount > 0 {
|
||||||
|
goldCost := costFunc.Evaluate(consumedCount)
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
|
||||||
|
log.Printf("[WeaponService] LimitBreakByWeapon: gold cost=%d", goldCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
weapon.LimitBreakCount += consumedCount
|
||||||
|
weapon.LatestVersion = nowMillis
|
||||||
|
user.Weapons[req.UserWeaponUuid] = weapon
|
||||||
|
|
||||||
|
note := user.WeaponNotes[weapon.WeaponId]
|
||||||
|
if note.MaxLimitBreakCount < weapon.LimitBreakCount {
|
||||||
|
note.MaxLimitBreakCount = weapon.LimitBreakCount
|
||||||
|
note.LatestVersion = nowMillis
|
||||||
|
user.WeaponNotes[weapon.WeaponId] = note
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[WeaponService] LimitBreakByWeapon: weaponId=%d limitBreak -> %d (consumed %d weapons)", weapon.WeaponId, weapon.LimitBreakCount, consumedCount)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("weapon limit break by weapon: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), limitBreakDiffTables)
|
||||||
|
diff := tracker.Apply(snapshot, tables)
|
||||||
|
|
||||||
|
return &pb.LimitBreakByWeaponResponse{DiffUserData: diff}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.EnhanceByWeaponRequest) (*pb.EnhanceByWeaponResponse, error) {
|
||||||
|
log.Printf("[WeaponService] EnhanceByWeapon: uuid=%s materialUuids=%v", req.UserWeaponUuid, req.MaterialUserWeaponUuids)
|
||||||
|
|
||||||
|
userId := currentUserId(ctx, s.users, s.sessions)
|
||||||
|
nowMillis := gametime.NowMillis()
|
||||||
|
|
||||||
|
oldUser, _ := s.users.SnapshotUser(userId)
|
||||||
|
tracker := userdata.NewDeleteTracker().
|
||||||
|
Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}).
|
||||||
|
Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}).
|
||||||
|
Track("IUserWeaponAbility", oldUser, userdata.SortedWeaponAbilityRecords, []string{"userId", "userWeaponUuid", "slotNumber"})
|
||||||
|
|
||||||
|
snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||||
|
weapon, ok := user.Weapons[req.UserWeaponUuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] EnhanceByWeapon: weapon uuid=%s not found", req.UserWeaponUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wm, ok := s.catalog.Weapons[weapon.WeaponId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] EnhanceByWeapon: weapon master id=%d not found", weapon.WeaponId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalExp := int32(0)
|
||||||
|
consumedCount := int32(0)
|
||||||
|
for _, uuid := range req.MaterialUserWeaponUuids {
|
||||||
|
matWeapon, ok := user.Weapons[uuid]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] EnhanceByWeapon: material weapon uuid=%s not found, skipping", uuid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matMaster, ok := s.catalog.Weapons[matWeapon.WeaponId]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[WeaponService] EnhanceByWeapon: material weapon master id=%d not found, skipping", matWeapon.WeaponId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
baseExp := s.catalog.BaseExpByEnhanceId[matMaster.WeaponSpecificEnhanceId]
|
||||||
|
if matMaster.WeaponType != 0 && matMaster.WeaponType == wm.WeaponType {
|
||||||
|
baseExp = baseExp * s.config.MaterialSameWeaponExpCoefficientPermil / 1000
|
||||||
|
}
|
||||||
|
totalExp += baseExp
|
||||||
|
|
||||||
|
if medals, ok := s.catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
|
||||||
|
for itemId, count := range medals {
|
||||||
|
user.ConsumableItems[itemId] += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(user.Weapons, uuid)
|
||||||
|
delete(user.WeaponSkills, uuid)
|
||||||
|
delete(user.WeaponAbilities, uuid)
|
||||||
|
consumedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if costFunc, ok := s.catalog.EnhanceCostByWeaponByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && consumedCount > 0 {
|
||||||
|
goldCost := costFunc.Evaluate(consumedCount)
|
||||||
|
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
|
||||||
|
log.Printf("[WeaponService] EnhanceByWeapon: gold cost=%d (weapons=%d)", goldCost, consumedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
weapon.Exp += totalExp
|
||||||
|
if thresholds, ok := s.catalog.ExpByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
|
||||||
|
weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds)
|
||||||
|
}
|
||||||
|
|
||||||
|
weapon.LatestVersion = nowMillis
|
||||||
|
user.Weapons[req.UserWeaponUuid] = weapon
|
||||||
|
log.Printf("[WeaponService] EnhanceByWeapon: weaponId=%d +%d exp -> total=%d level=%d", weapon.WeaponId, totalExp, weapon.Exp, weapon.Level)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("weapon enhance by weapon: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), weaponDiffTables)
|
||||||
|
diff := tracker.Apply(snapshot, tables)
|
||||||
|
|
||||||
|
return &pb.EnhanceByWeaponResponse{
|
||||||
|
IsGreatSuccess: false,
|
||||||
|
SurplusEnhanceWeapon: []string{},
|
||||||
|
DiffUserData: diff,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeaponServiceServer) checkEvolutionStoryUnlocks(user *store.UserState, weaponId int32, nowMillis int64) {
|
||||||
|
wm, ok := s.catalog.Weapons[weaponId]
|
||||||
|
if !ok || wm.WeaponStoryReleaseConditionGroupId == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
evoOrder, hasEvo := s.catalog.EvolutionOrder[weaponId]
|
||||||
|
conditions := s.catalog.ReleaseConditionsByGroupId[wm.WeaponStoryReleaseConditionGroupId]
|
||||||
|
for _, cond := range conditions {
|
||||||
|
switch cond.WeaponStoryReleaseConditionType {
|
||||||
|
case model.WeaponStoryReleaseConditionTypeReachSpecifiedEvolutionCount:
|
||||||
|
if hasEvo && evoOrder >= cond.ConditionValue {
|
||||||
|
store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
|
||||||
|
}
|
||||||
|
case model.WeaponStoryReleaseConditionTypeAcquisition:
|
||||||
|
store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user