···4646| `--grpc-port` | `8003` | gRPC server port |
4747| `--cdn-port` | `8080` | CDN server port |
4848| `--auth-port` | `3000` | Auth server port |
4949+| `--admin-port` | `0` | Admin webhook port (`0` = disabled). Bound on `127.0.0.1`; only takes effect when `LUNAR_ADMIN_TOKEN` is set in the env. |
49505051Custom ports are saved to `.wizard.json` alongside your other settings. On the next run the saved ports are reused automatically — no need to pass the flags again. If you later pass different port flags, the wizard warns you that the ports changed and asks for confirmation before continuing.
5152···172173| `--grpc.public-addr` | `10.0.2.2:8003` | lunar-tear externally-reachable addr |
173174| `--grpc.octo-url` | `http://10.0.2.2:8080` | Octo CDN base URL passed to lunar-tear |
174175| `--grpc.auth-url` | `http://localhost:3000` | auth server base URL passed to lunar-tear |
176176+| `--admin.listen` | *(empty)* | lunar-tear admin webhook bind. Empty = leave default; webhook only binds when `LUNAR_ADMIN_TOKEN` is set in the env. |
175177| `--no-color` | `false` | disable colored output |
176178177179### Ports
···180182| -------- | ---- | ------------- | ----------------------------------------------------------- |
181183| gRPC | 443 | `lunar-tear` | default; configurable with `--listen` (requires patched client) |
182184| HTTP | 8080 | `octo-cdn` | Octo asset API + game web pages |
185185+| HTTP | 8082 | `lunar-tear` | admin webhook (`/api/admin/master-data/reload`); loopback by default, only binds when `LUNAR_ADMIN_TOKEN` is set |
186186+| HTTP | 3000 | `auth-server` | account registration and login |
183187184188### Game Server Flags (`lunar-tear`)
185189186186-| Flag | Default | Description |
187187-| --------------- | ----------------- | ---------------------------------------------------- |
188188-| `--listen` | `0.0.0.0:443` | gRPC listen address (host:port) |
189189-| `--public-addr` | `127.0.0.1:443` | externally-reachable host:port advertised to clients |
190190-| `--octo-url` | *(required)* | CDN base URL the client uses for assets (e.g. `http://10.0.2.2:8080`) |
191191-| `--db` | `db/game.db` | SQLite database path |
192192-| `--auth-url` | *(empty)* | Auth server base URL (e.g. `http://localhost:3000`) |
190190+| Flag | Default | Description |
191191+| ---------------- | ----------------- | ---------------------------------------------------- |
192192+| `--listen` | `0.0.0.0:443` | gRPC listen address (host:port) |
193193+| `--public-addr` | `127.0.0.1:443` | externally-reachable host:port advertised to clients |
194194+| `--octo-url` | *(required)* | CDN base URL the client uses for assets (e.g. `http://10.0.2.2:8080`) |
195195+| `--db` | `db/game.db` | SQLite database path |
196196+| `--auth-url` | *(empty)* | Auth server base URL (e.g. `http://localhost:3000`) |
197197+| `--admin-listen` | `127.0.0.1:8082` | Admin webhook listen address. Only binds when `LUNAR_ADMIN_TOKEN` is set. |
198198+199199+### Live Master Data Reload
200200+201201+The game server reads its master data from `assets/release/20240404193219.bin.e` at startup. To swap in updated content **without restarting** the server:
202202+203203+1. Replace `assets/release/20240404193219.bin.e` on disk with your edited copy.
204204+2. POST to the admin webhook with a Bearer token matching `LUNAR_ADMIN_TOKEN`:
205205+206206+```bash
207207+curl -X POST -H "Authorization: Bearer ${LUNAR_ADMIN_TOKEN}" \
208208+ http://127.0.0.1:8082/api/admin/master-data/reload
209209+```
210210+211211+The server re-reads the file, atomically swaps every in-memory catalog and derived handler, and bumps the file's mtime. The mtime is folded into `GetLatestMasterDataVersion`, so connected clients see a new version string and re-download the file from the CDN on their next poll.
212212+213213+Security defaults are fail-closed:
214214+215215+- `LUNAR_ADMIN_TOKEN` **must** be set in the environment, or the webhook listener never binds.
216216+- `--admin-listen` defaults to `127.0.0.1:8082` (loopback only). Bind to `0.0.0.0` only if you intend to expose it.
217217+- Authentication uses constant-time Bearer-token comparison.
193218194219### CDN Flags (`octo-cdn`)
195220···214239215240| Service | Image | Default Port | Notes |
216241| -------- | --------------------------- | ------------ | ------------------------------ |
217217-| `server` | `kretts/lunar-tear:latest` | 8003 | gRPC game server |
242242+| `server` | `kretts/lunar-tear:latest` | 8003, 8082 | gRPC game server + admin webhook |
218243| `cdn` | `kretts/octo-cdn:latest` | 8080 | HTTP asset CDN |
219244| `auth` | `kretts/auth-server:latest` | 3000 | Account registration and login |
220245221221-The game server is configured via environment variables in the compose file: `LUNAR_LISTEN` (bind address), `LUNAR_PUBLIC_ADDR` (client-facing address), `LUNAR_OCTO_URL`, and `LUNAR_AUTH_URL`. Auth is optional — if `LUNAR_AUTH_URL` is unset the game server starts without it.
246246+The game server is configured via environment variables in the compose file:
247247+248248+| Env var | Description |
249249+| --------------------- | -------------------------------------------------------------------------------------------- |
250250+| `LUNAR_LISTEN` | gRPC bind address |
251251+| `LUNAR_PUBLIC_ADDR` | Client-facing address advertised to the game |
252252+| `LUNAR_OCTO_URL` | CDN base URL the client uses for assets |
253253+| `LUNAR_AUTH_URL` | Auth server base URL (optional) |
254254+| `LUNAR_ADMIN_LISTEN` | Admin webhook bind address inside the container (compose default: `0.0.0.0:8082`) |
255255+| `LUNAR_ADMIN_TOKEN` | Bearer token for the admin webhook. **The webhook does not bind unless this is set.** |
256256+257257+Auth is optional — if `LUNAR_AUTH_URL` is unset the game server starts without it. The admin webhook is published to `127.0.0.1:8082` on the host so the master-data reload endpoint stays loopback-only by default; set `LUNAR_ADMIN_TOKEN` (e.g. via a `.env` file) before bringing the stack up.
222258223259### Makefile Targets
224260
+22-5
server/cmd/dev/main.go
···9393 grpcOctoURL := flag.String("grpc.octo-url", "", "Octo CDN base URL passed to lunar-tear (default: derived from cdn.public-addr)")
9494 grpcAuthURL := flag.String("grpc.auth-url", "", "auth server base URL passed to lunar-tear (default: derived from auth.listen)")
95959696+ // admin webhook is opt-in; empty leaves lunar-tear's own default in place
9797+ // (the listener still only binds if LUNAR_ADMIN_TOKEN is set in the env).
9898+ adminListen := flag.String("admin.listen", "", "lunar-tear admin webhook listen address (host:port). Empty = leave default; webhook only binds when LUNAR_ADMIN_TOKEN is set in the env.")
9999+96100 noColor := flag.Bool("no-color", false, "disable colored output")
97101 flag.Parse()
98102···139143 label: "grpc",
140144 color: colorYellow,
141145 cmd: exec.CommandContext(ctx, filepath.Join("bin", "lunar-tear"+ext),
142142- "--listen", *grpcListen,
143143- "--public-addr", *grpcPublicAddr,
144144- "--db", *grpcDB,
145145- "--octo-url", *grpcOctoURL,
146146- "--auth-url", *grpcAuthURL,
146146+ grpcArgs(*grpcListen, *grpcPublicAddr, *grpcDB, *grpcOctoURL, *grpcAuthURL, *adminListen)...,
147147 ),
148148 },
149149 }
···200200 fmt.Printf("%s%s\n", prefix, scanner.Text())
201201 }
202202}
203203+204204+// grpcArgs assembles the argv for the lunar-tear subprocess. The admin flag
205205+// is appended only when --admin.listen was supplied so we don't override
206206+// lunar-tear's own default when the operator hasn't opted in.
207207+func grpcArgs(listen, publicAddr, db, octoURL, authURL, adminListen string) []string {
208208+ args := []string{
209209+ "--listen", listen,
210210+ "--public-addr", publicAddr,
211211+ "--db", db,
212212+ "--octo-url", octoURL,
213213+ "--auth-url", authURL,
214214+ }
215215+ if adminListen != "" {
216216+ args = append(args, "--admin-listen", adminListen)
217217+ }
218218+ return args
219219+}
+56
server/cmd/lunar-tear/admin.go
···11+package main
22+33+import (
44+ "crypto/subtle"
55+ "log"
66+ "net/http"
77+ "os"
88+99+ "lunar-tear/server/internal/runtime"
1010+)
1111+1212+// startAdmin spins up the admin webhook used by external content tools to
1313+// trigger an in-place re-read of assets/release/20240404193219.bin.e.
1414+//
1515+// Authentication: Bearer token via the LUNAR_ADMIN_TOKEN environment variable.
1616+// If LUNAR_ADMIN_TOKEN is unset or empty the listener does not bind at all
1717+// (fail closed), so a fresh deploy never exposes an unauthenticated endpoint.
1818+//
1919+// The default --admin-listen is 127.0.0.1:8082 so the webhook is only
2020+// reachable via loopback unless the operator opts in by binding to 0.0.0.0.
2121+func startAdmin(listen string, holder *runtime.Holder) {
2222+ token := os.Getenv("LUNAR_ADMIN_TOKEN")
2323+ if token == "" {
2424+ log.Println("[admin] disabled (no LUNAR_ADMIN_TOKEN set)")
2525+ return
2626+ }
2727+ expected := []byte("Bearer " + token)
2828+2929+ mux := http.NewServeMux()
3030+ mux.HandleFunc("/api/admin/master-data/reload", func(w http.ResponseWriter, r *http.Request) {
3131+ if r.Method != http.MethodPost {
3232+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
3333+ return
3434+ }
3535+ got := []byte(r.Header.Get("Authorization"))
3636+ if len(got) != len(expected) || subtle.ConstantTimeCompare(got, expected) != 1 {
3737+ http.Error(w, "unauthorized", http.StatusUnauthorized)
3838+ return
3939+ }
4040+ if err := holder.Reload(); err != nil {
4141+ log.Printf("[admin] master-data reload failed: %v", err)
4242+ http.Error(w, "master-data reload failed", http.StatusInternalServerError)
4343+ return
4444+ }
4545+ log.Printf("[admin] master-data reloaded by %s", r.RemoteAddr)
4646+ w.Header().Set("Content-Type", "application/json")
4747+ _, _ = w.Write([]byte(`{"ok":true}`))
4848+ })
4949+5050+ log.Printf("[admin] webhook listener on %s (token-gated)", listen)
5151+ go func() {
5252+ if err := http.ListenAndServe(listen, mux); err != nil {
5353+ log.Printf("[admin] webhook listener failed: %v", err)
5454+ }
5555+ }()
5656+}
···11+// Package runtime owns the live, hot-swappable view of master data.
22+//
33+// The Holder atomically swaps a *Catalogs aggregate every time the operator
44+// asks the server to re-read assets/release/20240404193219.bin.e (typically via
55+// the admin webhook in cmd/lunar-tear/admin.go). gRPC services hold a *Holder
66+// and call Get() at the start of each RPC, so they always see a consistent
77+// snapshot.
88+package runtime
99+1010+import (
1111+ "fmt"
1212+ "log"
1313+ "os"
1414+ "sync/atomic"
1515+ "time"
1616+1717+ "lunar-tear/server/internal/gacha"
1818+ "lunar-tear/server/internal/masterdata"
1919+ "lunar-tear/server/internal/masterdata/memorydb"
2020+ "lunar-tear/server/internal/model"
2121+ "lunar-tear/server/internal/questflow"
2222+ "lunar-tear/server/internal/store"
2323+)
2424+2525+// Catalogs is an immutable snapshot of every catalog and catalog-derived
2626+// handler the server needs at runtime. A new *Catalogs is built from scratch
2727+// on every reload and atomically published via Holder.
2828+type Catalogs struct {
2929+ GameConfig *masterdata.GameConfig
3030+ Parts *masterdata.PartsCatalog
3131+ Quest *masterdata.QuestCatalog
3232+ GachaEntries []store.GachaCatalogEntry
3333+ GachaMedals map[int32]masterdata.GachaMedalInfo
3434+ GachaPool *masterdata.GachaCatalog
3535+ Shop *masterdata.ShopCatalog
3636+ DupExchange map[int32][]model.DupExchangeEntry
3737+ ConditionResolver *masterdata.ConditionResolver
3838+ CageOrnament *masterdata.CageOrnamentCatalog
3939+ LoginBonus *masterdata.LoginBonusCatalog
4040+ CharacterViewer *masterdata.CharacterViewerCatalog
4141+ Omikuji *masterdata.OmikujiCatalog
4242+ Material *masterdata.MaterialCatalog
4343+ ConsumableItem *masterdata.ConsumableItemCatalog
4444+ Costume *masterdata.CostumeCatalog
4545+ Weapon *masterdata.WeaponCatalog
4646+ Explore *masterdata.ExploreCatalog
4747+ Gimmick *masterdata.GimmickCatalog
4848+ CharacterBoard *masterdata.CharacterBoardCatalog
4949+ CharacterRebirth *masterdata.CharacterRebirthCatalog
5050+ Companion *masterdata.CompanionCatalog
5151+ SideStory *masterdata.SideStoryCatalog
5252+ BigHunt *masterdata.BigHuntCatalog
5353+5454+ // Catalog-derived handlers must rebuild on every reload because they
5555+ // embed/cache pointers to specific catalog instances.
5656+ QuestHandler *questflow.QuestHandler
5757+ GachaHandler *gacha.GachaHandler
5858+}
5959+6060+// Holder owns the current *Catalogs and the bin.e path. Concurrent readers
6161+// call Get(); the single-writer Reload() rebuilds and atomically publishes.
6262+type Holder struct {
6363+ binPath string
6464+ cur atomic.Pointer[Catalogs]
6565+}
6666+6767+// NewHolder reads the binary at binPath, builds the initial catalogs, and
6868+// returns a ready-to-use Holder. Subsequent calls to Reload() re-read the
6969+// same path.
7070+func NewHolder(binPath string) (*Holder, error) {
7171+ h := &Holder{binPath: binPath}
7272+ if err := h.Reload(); err != nil {
7373+ return nil, err
7474+ }
7575+ return h, nil
7676+}
7777+7878+// Reload re-reads the bin.e from disk, rebuilds every catalog and handler,
7979+// atomically publishes the new snapshot, and bumps the bin.e mtime so client
8080+// caches invalidate (see service/data.go GetLatestMasterDataVersion).
8181+func (h *Holder) Reload() error {
8282+ if err := memorydb.Init(h.binPath); err != nil {
8383+ return fmt.Errorf("memorydb.Init: %w", err)
8484+ }
8585+ c, err := buildCatalogs()
8686+ if err != nil {
8787+ return fmt.Errorf("buildCatalogs: %w", err)
8888+ }
8989+ h.cur.Store(c)
9090+ now := time.Now()
9191+ if err := os.Chtimes(h.binPath, now, now); err != nil {
9292+ // Non-fatal: the catalogs swapped fine in-memory; clients may take
9393+ // longer to invalidate their cached download but server-side state is
9494+ // already coherent.
9595+ log.Printf("[runtime] os.Chtimes(%s) failed (clients may not invalidate cache): %v", h.binPath, err)
9696+ }
9797+ return nil
9898+}
9999+100100+// Get returns the current snapshot. Safe for concurrent callers; the returned
101101+// pointer is stable for the duration of the caller's use.
102102+func (h *Holder) Get() *Catalogs {
103103+ return h.cur.Load()
104104+}
···1313 "lunar-tear/server/internal/gameutil"
1414 "lunar-tear/server/internal/masterdata"
1515 "lunar-tear/server/internal/model"
1616+ "lunar-tear/server/internal/runtime"
1617 "lunar-tear/server/internal/store"
1718)
1819···2021 pb.UnimplementedCostumeServiceServer
2122 users store.UserRepository
2223 sessions store.SessionRepository
2323- catalog *masterdata.CostumeCatalog
2424- config *masterdata.GameConfig
2424+ holder *runtime.Holder
2525}
26262727-func NewCostumeServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CostumeCatalog, config *masterdata.GameConfig) *CostumeServiceServer {
2828- return &CostumeServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
2727+func NewCostumeServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *CostumeServiceServer {
2828+ return &CostumeServiceServer{users: users, sessions: sessions, holder: holder}
2929}
30303131func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceRequest) (*pb.EnhanceResponse, error) {
3232 log.Printf("[CostumeService] Enhance: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
33333434+ cat := s.holder.Get()
3535+ catalog := cat.Costume
3636+ config := cat.GameConfig
3437 userId := CurrentUserId(ctx, s.users, s.sessions)
3538 nowMillis := gametime.NowMillis()
3639···4144 return
4245 }
43464444- cm, ok := s.catalog.Costumes[costume.CostumeId]
4747+ cm, ok := catalog.Costumes[costume.CostumeId]
4548 if !ok {
4649 log.Printf("[CostumeService] Enhance: costume master id=%d not found", costume.CostumeId)
4750 return
···5053 totalExp := int32(0)
5154 totalMaterialCount := int32(0)
5255 for materialId, count := range req.Materials {
5353- mat, ok := s.catalog.Materials[materialId]
5656+ mat, ok := catalog.Materials[materialId]
5457 if !ok {
5558 log.Printf("[CostumeService] Enhance: material id=%d not found, skipping", materialId)
5659 continue
···66696770 expPerUnit := mat.EffectValue
6871 if mat.WeaponType != 0 && mat.WeaponType == cm.SkillfulWeaponType {
6969- expPerUnit = expPerUnit * s.config.MaterialSameWeaponExpCoefficientPermil / 1000
7272+ expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000
7073 }
7174 totalExp += expPerUnit * count
7275 }
73767474- if costFunc, ok := s.catalog.EnhanceCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
7777+ if costFunc, ok := catalog.EnhanceCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
7578 goldCost := costFunc.Evaluate(totalMaterialCount)
7676- user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
7979+ user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
7780 log.Printf("[CostumeService] Enhance: gold cost=%d (materials=%d)", goldCost, totalMaterialCount)
7881 }
79828083 costume.Exp += totalExp
81848282- if thresholds, ok := s.catalog.ExpByRarity[cm.RarityType]; ok {
8585+ if thresholds, ok := catalog.ExpByRarity[cm.RarityType]; ok {
8386 costume.Level, costume.Exp = gameutil.LevelAndCap(costume.Exp, thresholds)
8487 }
8588···100103func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest) (*pb.AwakenResponse, error) {
101104 log.Printf("[CostumeService] Awaken: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
102105106106+ cat := s.holder.Get()
107107+ catalog := cat.Costume
108108+ config := cat.GameConfig
103109 userId := CurrentUserId(ctx, s.users, s.sessions)
104110 nowMillis := gametime.NowMillis()
105111···110116 return
111117 }
112118113113- awakenRow, ok := s.catalog.AwakenByCostumeId[costume.CostumeId]
119119+ awakenRow, ok := catalog.AwakenByCostumeId[costume.CostumeId]
114120 if !ok {
115121 log.Printf("[CostumeService] Awaken: no awaken data for costumeId=%d", costume.CostumeId)
116122 return
···118124119125 nextStep := costume.AwakenCount + 1
120126121121- if gold, ok := s.catalog.AwakenPriceByGroup[awakenRow.CostumeAwakenPriceGroupId]; ok {
122122- user.ConsumableItems[s.config.ConsumableItemIdForGold] -= gold
127127+ if gold, ok := catalog.AwakenPriceByGroup[awakenRow.CostumeAwakenPriceGroupId]; ok {
128128+ user.ConsumableItems[config.ConsumableItemIdForGold] -= gold
123129 log.Printf("[CostumeService] Awaken: gold cost=%d", gold)
124130 }
125131···137143 user.Costumes[req.UserCostumeUuid] = costume
138144 log.Printf("[CostumeService] Awaken: costumeId=%d awakenCount=%d", costume.CostumeId, nextStep)
139145140140- effectSteps, ok := s.catalog.AwakenEffectsByGroupAndStep[awakenRow.CostumeAwakenEffectGroupId]
146146+ effectSteps, ok := catalog.AwakenEffectsByGroupAndStep[awakenRow.CostumeAwakenEffectGroupId]
141147 if !ok {
142148 return
143149 }
···148154149155 switch model.CostumeAwakenEffectType(effect.CostumeAwakenEffectType) {
150156 case model.CostumeAwakenEffectTypeStatusUp:
151151- s.applyAwakenStatusUp(user, req.UserCostumeUuid, effect.CostumeAwakenEffectId, nowMillis)
157157+ applyCostumeAwakenStatusUp(catalog, user, req.UserCostumeUuid, effect.CostumeAwakenEffectId, nowMillis)
152158 case model.CostumeAwakenEffectTypeAbility:
153159 log.Printf("[CostumeService] Awaken: ability effect id=%d (client-resolved)", effect.CostumeAwakenEffectId)
154160 case model.CostumeAwakenEffectTypeItemAcquire:
155155- s.applyAwakenItemAcquire(user, effect.CostumeAwakenEffectId, nowMillis)
161161+ applyCostumeAwakenItemAcquire(catalog, user, effect.CostumeAwakenEffectId, nowMillis)
156162 default:
157163 log.Printf("[CostumeService] Awaken: unknown effect type=%d", effect.CostumeAwakenEffectType)
158164 }
···164170 return &pb.AwakenResponse{}, nil
165171}
166172167167-func (s *CostumeServiceServer) applyAwakenStatusUp(user *store.UserState, costumeUuid string, statusUpGroupId int32, nowMillis int64) {
168168- rows, ok := s.catalog.AwakenStatusUpByGroup[statusUpGroupId]
173173+func applyCostumeAwakenStatusUp(catalog *masterdata.CostumeCatalog, user *store.UserState, costumeUuid string, statusUpGroupId int32, nowMillis int64) {
174174+ rows, ok := catalog.AwakenStatusUpByGroup[statusUpGroupId]
169175 if !ok {
170176 log.Printf("[CostumeService] Awaken: status up group %d not found", statusUpGroupId)
171177 return
···201207 }
202208}
203209204204-func (s *CostumeServiceServer) applyAwakenItemAcquire(user *store.UserState, itemAcquireId int32, nowMillis int64) {
205205- acq, ok := s.catalog.AwakenItemAcquireById[itemAcquireId]
210210+func applyCostumeAwakenItemAcquire(catalog *masterdata.CostumeCatalog, user *store.UserState, itemAcquireId int32, nowMillis int64) {
211211+ acq, ok := catalog.AwakenItemAcquireById[itemAcquireId]
206212 if !ok {
207213 log.Printf("[CostumeService] Awaken: item acquire id=%d not found", itemAcquireId)
208214 return
···226232func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.EnhanceActiveSkillRequest) (*pb.EnhanceActiveSkillResponse, error) {
227233 log.Printf("[CostumeService] EnhanceActiveSkill: uuid=%s addLevel=%d", req.UserCostumeUuid, req.AddLevelCount)
228234235235+ cat := s.holder.Get()
236236+ catalog := cat.Costume
237237+ config := cat.GameConfig
229238 userId := CurrentUserId(ctx, s.users, s.sessions)
230239 nowMillis := gametime.NowMillis()
231240···236245 return
237246 }
238247239239- cm, ok := s.catalog.Costumes[costume.CostumeId]
248248+ cm, ok := catalog.Costumes[costume.CostumeId]
240249 if !ok {
241250 log.Printf("[CostumeService] EnhanceActiveSkill: costume master id=%d not found", costume.CostumeId)
242251 return
243252 }
244253245245- groupRows := s.catalog.ActiveSkillGroupsByGroupId[cm.CostumeActiveSkillGroupId]
254254+ groupRows := catalog.ActiveSkillGroupsByGroupId[cm.CostumeActiveSkillGroupId]
246255 enhanceMatId := int32(-1)
247256 for _, g := range groupRows {
248257 if g.CostumeLimitBreakCountLowerLimit <= costume.LimitBreakCount {
···259268 skill := user.CostumeActiveSkills[req.UserCostumeUuid]
260269 currentLevel := skill.Level
261270262262- maxLevelFunc, ok := s.catalog.ActiveSkillMaxLevelByRarity[cm.RarityType]
271271+ maxLevelFunc, ok := catalog.ActiveSkillMaxLevelByRarity[cm.RarityType]
263272 if !ok {
264273 log.Printf("[CostumeService] EnhanceActiveSkill: no max level func for rarity=%d", cm.RarityType)
265274 return
···277286278287 for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
279288 key := [2]int32{enhanceMatId, lvl}
280280- mats := s.catalog.ActiveSkillEnhanceMats[key]
289289+ mats := catalog.ActiveSkillEnhanceMats[key]
281290 for _, mat := range mats {
282291 cur := user.Materials[mat.MaterialId]
283292 cost := mat.Count
···288297 user.Materials[mat.MaterialId] = cur - cost
289298 }
290299291291- if costFunc, ok := s.catalog.ActiveSkillCostByRarity[cm.RarityType]; ok {
300300+ if costFunc, ok := catalog.ActiveSkillCostByRarity[cm.RarityType]; ok {
292301 goldCost := costFunc.Evaluate(lvl + 1)
293293- user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
302302+ user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
294303 }
295304 }
296305···310319func (s *CostumeServiceServer) LimitBreak(ctx context.Context, req *pb.LimitBreakRequest) (*pb.LimitBreakResponse, error) {
311320 log.Printf("[CostumeService] LimitBreak: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
312321322322+ cat := s.holder.Get()
323323+ catalog := cat.Costume
324324+ config := cat.GameConfig
313325 userId := CurrentUserId(ctx, s.users, s.sessions)
314326 nowMillis := gametime.NowMillis()
315327···320332 return
321333 }
322334323323- if costume.LimitBreakCount >= s.config.CostumeLimitBreakAvailableCount {
335335+ if costume.LimitBreakCount >= config.CostumeLimitBreakAvailableCount {
324336 log.Printf("[CostumeService] LimitBreak: already at max limit break %d", costume.LimitBreakCount)
325337 return
326338 }
327339328328- cm, ok := s.catalog.Costumes[costume.CostumeId]
340340+ cm, ok := catalog.Costumes[costume.CostumeId]
329341 if !ok {
330342 log.Printf("[CostumeService] LimitBreak: costume master id=%d not found", costume.CostumeId)
331343 return
···342354 totalMaterialCount += count
343355 }
344356345345- if costFunc, ok := s.catalog.LimitBreakCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
357357+ if costFunc, ok := catalog.LimitBreakCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
346358 goldCost := costFunc.Evaluate(totalMaterialCount)
347347- user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
359359+ user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
348360 log.Printf("[CostumeService] LimitBreak: gold cost=%d", goldCost)
349361 }
350362···363375func (s *CostumeServiceServer) UnlockLotteryEffectSlot(ctx context.Context, req *pb.UnlockLotteryEffectSlotRequest) (*pb.UnlockLotteryEffectSlotResponse, error) {
364376 log.Printf("[CostumeService] UnlockLotteryEffectSlot: uuid=%s slot=%d", req.UserCostumeUuid, req.SlotNumber)
365377378378+ cat := s.holder.Get()
379379+ catalog := cat.Costume
380380+ config := cat.GameConfig
366381 userId := CurrentUserId(ctx, s.users, s.sessions)
367382 nowMillis := gametime.NowMillis()
368383···373388 return
374389 }
375390376376- effectRow, ok := s.catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}]
391391+ effectRow, ok := catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}]
377392 if !ok {
378393 log.Printf("[CostumeService] UnlockLotteryEffectSlot: no lottery effect for costumeId=%d slot=%d", costume.CostumeId, req.SlotNumber)
379394 return
380395 }
381396382382- user.ConsumableItems[s.config.ConsumableItemIdForGold] -= s.config.CostumeLotteryEffectUnlockSlotConsumeGold
397397+ user.ConsumableItems[config.ConsumableItemIdForGold] -= config.CostumeLotteryEffectUnlockSlotConsumeGold
383398384384- mats := s.catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectUnlockMaterialGroupId]
399399+ mats := catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectUnlockMaterialGroupId]
385400 for _, mat := range mats {
386401 cur := user.Materials[mat.MaterialId]
387402 cost := mat.Count
···418433func (s *CostumeServiceServer) DrawLotteryEffect(ctx context.Context, req *pb.DrawLotteryEffectRequest) (*pb.DrawLotteryEffectResponse, error) {
419434 log.Printf("[CostumeService] DrawLotteryEffect: uuid=%s slot=%d", req.UserCostumeUuid, req.SlotNumber)
420435436436+ cat := s.holder.Get()
437437+ catalog := cat.Costume
438438+ config := cat.GameConfig
421439 userId := CurrentUserId(ctx, s.users, s.sessions)
422440 nowMillis := gametime.NowMillis()
423441···428446 return
429447 }
430448431431- effectRow, ok := s.catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}]
449449+ effectRow, ok := catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}]
432450 if !ok {
433451 log.Printf("[CostumeService] DrawLotteryEffect: no lottery effect for costumeId=%d slot=%d", costume.CostumeId, req.SlotNumber)
434452 return
435453 }
436454437437- oddsPool := s.catalog.LotteryEffectOdds[effectRow.CostumeLotteryEffectOddsGroupId]
455455+ oddsPool := catalog.LotteryEffectOdds[effectRow.CostumeLotteryEffectOddsGroupId]
438456 if len(oddsPool) == 0 {
439457 log.Printf("[CostumeService] DrawLotteryEffect: empty odds pool for groupId=%d", effectRow.CostumeLotteryEffectOddsGroupId)
440458 return
441459 }
442460443443- user.ConsumableItems[s.config.ConsumableItemIdForGold] -= s.config.CostumeLotteryEffectDrawSlotConsumeGold
461461+ user.ConsumableItems[config.ConsumableItemIdForGold] -= config.CostumeLotteryEffectDrawSlotConsumeGold
444462445445- mats := s.catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectDrawMaterialGroupId]
463463+ mats := catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectDrawMaterialGroupId]
446464 for _, mat := range mats {
447465 cur := user.Materials[mat.MaterialId]
448466 cost := mat.Count
+19-2
server/internal/service/data.go
···44 "context"
55 "fmt"
66 "log"
77+ "os"
7889 pb "lunar-tear/server/gen/proto"
910 "lunar-tear/server/internal/store"
···11121213 "google.golang.org/protobuf/types/known/emptypb"
1314)
1515+1616+// masterDataBinPath is the canonical location of the encrypted master data
1717+// file. The mtime of this file is folded into the version string so the
1818+// client invalidates its cache as soon as an admin reload swaps it in.
1919+const masterDataBinPath = "assets/release/20240404193219.bin.e"
2020+2121+// masterDataBaseVersion preserves the historical "yyyymmddHHMMSS" value the
2222+// client has always seen; we suffix it with the file mtime to force a
2323+// re-download when content changes.
2424+const masterDataBaseVersion = "20240404193219"
14251526type DataServiceServer struct {
1627 pb.UnimplementedDataServiceServer
···2334}
24352536func (s *DataServiceServer) GetLatestMasterDataVersion(ctx context.Context, _ *emptypb.Empty) (*pb.MasterDataGetLatestVersionResponse, error) {
2626- log.Printf("[DataService] GetLatestMasterDataVersion")
3737+ version := masterDataBaseVersion
3838+ if info, err := os.Stat(masterDataBinPath); err == nil {
3939+ version = fmt.Sprintf("%s_%d", masterDataBaseVersion, info.ModTime().UnixMilli())
4040+ } else {
4141+ log.Printf("[DataService] stat %s: %v (falling back to base version)", masterDataBinPath, err)
4242+ }
4343+ log.Printf("[DataService] GetLatestMasterDataVersion -> %s", version)
2744 return &pb.MasterDataGetLatestVersionResponse{
2828- LatestMasterDataVersion: "20240404193219",
4545+ LatestMasterDataVersion: version,
2946 }, nil
3047}
3148