diff --git a/backend/go/README.md b/backend/go/README.md new file mode 100644 index 00000000..6d7e37dc --- /dev/null +++ b/backend/go/README.md @@ -0,0 +1,53 @@ +# dcalc Go 重构骨架 + +这个目录是对现有 Python 后端的并行 Go 重构,不直接替换原服务。 + +当前已迁移: + +- 通用响应结构与 HTTP 入口 +- CORS、压缩、中间件基础设施 +- 冒险团职业列表与 token 生成 +- openapi 静态数据读取接口 +- 基于 alter_token 的技能详情接口 +- colg 金价查询接口 +- /api/character 静态职业与装备目录信息 +- /api/skillTree/ 编码解码逻辑(Go 实现) +- Python 适配层(职业工厂依赖收口) + +兼容性说明: + +- jobInfo 接口保持与 Python 一致,返回目录名风格的 jobId 和 jobGrowId +- skills 和 skillDetail 路由同时兼容目录名参数与 Neople UUID 参数 + +当前未迁移: + +- 角色构建与伤害计算公式本体 +- DNFHelper 集成 +- MCP 服务 + +当前架构: + +- skillTree 的 base64/zlib/binary 解码已经迁移到 Go +- createCharacter 依赖通过 go/python_bridge.py 适配层收口 +- calc 域已拆出 CharacterFactory 与 EquipmentCatalogRepository 边界,便于后续逐步替换 Python 依赖 + +运行: + +```bash +cd go +go run ./cmd/dcalc +``` + +可选环境变量: + +- PORT +- DEBUG_MODE +- COLG_TOKEN +- HELPER_TOKEN +- HELPER_ID + +建议迁移顺序: + +1. 先把 createCharacter 工厂后的领域模型拆成 Go interface 和 package。 +2. 再迁装备/套装/技能数据库读取层。 +3. 最后逐职业迁技能公式与计算逻辑。 diff --git a/backend/go/cmd/dcalc/main.go b/backend/go/cmd/dcalc/main.go new file mode 100644 index 00000000..1ca45b94 --- /dev/null +++ b/backend/go/cmd/dcalc/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "log" + "net/http" + + "dcalc-go/internal/config" + "dcalc-go/internal/httpserver" + "dcalc-go/internal/service" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("load config: %v", err) + } + + cache := service.NewCache() + adventureService, err := service.NewAdventureService(cfg.ProjectRoot, cache) + if err != nil { + log.Fatalf("load adventure data: %v", err) + } + + openAPIService := service.NewOpenAPIService(cfg.ProjectRoot, cache, adventureService) + colgService := service.NewColgService(cache, cfg) + tokenService := service.NewTokenService(adventureService) + characterFactory := service.NewPythonCharacterFactory(cfg) + characterService := service.NewCharacterService(cache, characterFactory) + skillTreeService, err := service.NewSkillTreeService(cfg.ProjectRoot, adventureService, characterFactory) + if err != nil { + log.Fatalf("load skill tree service: %v", err) + } + equipmentRepository := service.NewSQLiteEquipmentCatalogRepository(cfg.ProjectRoot) + calcService := service.NewCalcService(characterFactory, equipmentRepository) + + router := httpserver.NewRouter(httpserver.Dependencies{ + Config: cfg, + AdventureService: adventureService, + OpenAPIService: openAPIService, + ColgService: colgService, + TokenService: tokenService, + CharacterService: characterService, + SkillTreeService: skillTreeService, + CalcService: calcService, + }) + + addr := ":" + cfg.Port + log.Printf("dcalc go server listening on %s", addr) + if err := http.ListenAndServe(addr, router); err != nil { + log.Fatalf("listen and serve: %v", err) + } +} diff --git a/backend/go/go.mod b/backend/go/go.mod new file mode 100644 index 00000000..f73c8732 --- /dev/null +++ b/backend/go/go.mod @@ -0,0 +1,20 @@ +module dcalc-go + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.2.3 + modernc.org/sqlite v1.48.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/backend/go/go.sum b/backend/go/go.sum new file mode 100644 index 00000000..3a9318dc --- /dev/null +++ b/backend/go/go.sum @@ -0,0 +1,53 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= +modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/go/internal/config/config.go b/backend/go/internal/config/config.go new file mode 100644 index 00000000..4d9d881c --- /dev/null +++ b/backend/go/internal/config/config.go @@ -0,0 +1,90 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strconv" +) + +type Config struct { + Port string + DebugMode bool + ColgToken string + HelperToken string + HelperID string + PythonExec string + ProjectRoot string +} + +func Load() (Config, error) { + projectRoot, err := detectProjectRoot() + if err != nil { + return Config{}, err + } + + return Config{ + Port: envOrDefault("PORT", "27173"), + DebugMode: envBoolOrDefault("DEBUG_MODE", true), + ColgToken: os.Getenv("COLG_TOKEN"), + HelperToken: os.Getenv("HELPER_TOKEN"), + HelperID: os.Getenv("HELPER_ID"), + PythonExec: pythonExecOrDefault(projectRoot), + ProjectRoot: projectRoot, + }, nil +} + +func pythonExecOrDefault(projectRoot string) string { + if value := os.Getenv("PYTHON_EXEC"); value != "" { + return value + } + venvPython := filepath.Join(projectRoot, ".venv", "bin", "python") + if fileExists(venvPython) { + return venvPython + } + return "python3" +} + +func detectProjectRoot() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", err + } + + current := wd + for { + if fileExists(filepath.Join(current, "openapi", "jobs.json")) { + return current, nil + } + + parent := filepath.Dir(current) + if parent == current { + return "", fmt.Errorf("unable to locate project root from %s", wd) + } + current = parent + } +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func envOrDefault(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func envBoolOrDefault(key string, fallback bool) bool { + value := os.Getenv(key) + if value == "" { + return fallback + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return fallback + } + return parsed +} diff --git a/backend/go/internal/httpserver/router.go b/backend/go/internal/httpserver/router.go new file mode 100644 index 00000000..22312201 --- /dev/null +++ b/backend/go/internal/httpserver/router.go @@ -0,0 +1,301 @@ +package httpserver + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "dcalc-go/internal/config" + "dcalc-go/internal/model" + "dcalc-go/internal/service" +) + +type Dependencies struct { + Config config.Config + AdventureService *service.AdventureService + OpenAPIService *service.OpenAPIService + ColgService *service.ColgService + TokenService *service.TokenService + CharacterService *service.CharacterService + SkillTreeService *service.SkillTreeService + CalcService *service.CalcService +} + +func NewRouter(dep Dependencies) http.Handler { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.Compress(5, "application/json")) + r.Use(cors) + + r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, model.Success(map[string]any{"service": "dcalc-go", "debug": dep.Config.DebugMode})) + }) + + r.Route("/api", func(r chi.Router) { + r.Get("/adventure", func(w http.ResponseWriter, r *http.Request) { + data, err := dep.AdventureService.AdventureList() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, model.Success(data)) + }) + + r.Get("/token/get/{alter}", func(w http.ResponseWriter, r *http.Request) { + token, err := dep.TokenService.Create(chi.URLParam(r, "alter"), r.URL.Query().Get("equVersion")) + if err != nil { + writeJSON(w, http.StatusBadRequest, model.Error(400, err.Error(), nil)) + return + } + writeJSON(w, http.StatusOK, model.Success(token)) + }) + + r.Get("/character", func(w http.ResponseWriter, r *http.Request) { + payload, ok := requireAlterToken(w, r, dep.TokenService) + if !ok { + return + } + data, err := dep.CharacterService.StaticInfo(payload.Alter, payload.EquVersion) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, model.Success(data)) + }) + + r.Post("/calc", func(w http.ResponseWriter, r *http.Request) { + payload, ok := requireAlterToken(w, r, dep.TokenService) + if !ok { + return + } + + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, http.ErrBodyNotAllowed) { + writeJSON(w, http.StatusBadRequest, model.Error(400, "invalid request body", nil)) + return + } + + data, err := dep.CalcService.Calculate(payload.Alter, payload.EquVersion, body) + if err != nil { + writeNotImplemented(w, err) + return + } + writeJSON(w, http.StatusOK, model.Success(data)) + }) + + r.Get("/skill/{skillId}/{level}", func(w http.ResponseWriter, r *http.Request) { + payload, ok := requireAlterToken(w, r, dep.TokenService) + if !ok { + return + } + level, err := strconv.Atoi(chi.URLParam(r, "level")) + if err != nil { + writeJSON(w, http.StatusBadRequest, model.Error(400, "level must be an integer", nil)) + return + } + data, err := dep.OpenAPIService.SkillDetailByAlter(payload.Alter, chi.URLParam(r, "skillId"), level) + if err != nil { + writeJSON(w, http.StatusNotFound, model.Error(404, err.Error(), nil)) + return + } + writeJSON(w, http.StatusOK, model.Success(data)) + }) + + r.Get("/dnfhelper/{uid}/", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusNotImplemented, model.Error(501, "dnfhelper integration is pending migration", nil)) + }) + + r.Post("/skillTree/", func(w http.ResponseWriter, r *http.Request) { + payload, ok := requireAlterToken(w, r, dep.TokenService) + if !ok { + return + } + + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) { + writeJSON(w, http.StatusBadRequest, model.Error(400, "invalid request body", nil)) + return + } + code, _ := body["code"].(string) + result, err := dep.SkillTreeService.Parse(code, payload.EquVersion) + if err != nil { + writeJSON(w, http.StatusInternalServerError, model.Error(500, "解析技能加点失败", nil)) + return + } + if result.Job["class"] != payload.Alter { + writeJSON(w, http.StatusInternalServerError, model.Error(500, "技能加点代码职业与登录职业不匹配", nil)) + return + } + writeJSON(w, http.StatusOK, model.Success(result)) + }) + + r.Route("/open", func(r chi.Router) { + r.Get("/skills/", func(w http.ResponseWriter, r *http.Request) { + data, err := dep.OpenAPIService.SearchSkills(r.URL.Query().Get("skillName")) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, model.Success(data)) + }) + + r.Get("/jobInfo/", func(w http.ResponseWriter, r *http.Request) { + data, err := dep.OpenAPIService.JobsInfo(r.URL.Query().Get("jobName")) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, model.Success(data)) + }) + + r.Get("/skillData/summary/", func(w http.ResponseWriter, r *http.Request) { + data, err := dep.OpenAPIService.SkillDataSummary( + r.URL.Query().Get("job"), + splitCSV(r.URL.Query().Get("skills")), + splitCSV(r.URL.Query().Get("weapons")), + ) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, model.Success(data)) + }) + + r.Get("/gold/realtime/", func(w http.ResponseWriter, r *http.Request) { + data, err := dep.ColgService.RealTimeGold(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, err) + return + } + writeJSON(w, http.StatusOK, model.Success(data)) + }) + + r.Get("/gold/history/", func(w http.ResponseWriter, r *http.Request) { + region, err := strconv.Atoi(r.URL.Query().Get("region")) + if err != nil { + writeJSON(w, http.StatusBadRequest, model.Error(400, "region must be an integer", nil)) + return + } + timeRange := 7 + if raw := r.URL.Query().Get("timeRange"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil { + writeJSON(w, http.StatusBadRequest, model.Error(400, "timeRange must be an integer", nil)) + return + } + timeRange = parsed + } + + data, err := dep.ColgService.HistoryGold(r.Context(), region, timeRange) + if err != nil { + writeJSON(w, http.StatusBadRequest, model.Error(404, err.Error(), nil)) + return + } + writeJSON(w, http.StatusOK, model.Success(data)) + }) + + r.Get("/gold/official/", func(w http.ResponseWriter, r *http.Request) { + data, err := dep.ColgService.OfficialGold(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, err) + return + } + writeJSON(w, http.StatusOK, model.Success(data)) + }) + + r.Get("/{jobId}/{jobGrowId}/skills/", func(w http.ResponseWriter, r *http.Request) { + data, err := dep.OpenAPIService.SkillsByJob(chi.URLParam(r, "jobId"), chi.URLParam(r, "jobGrowId")) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + writeJSON(w, http.StatusOK, model.Success(data)) + }) + + r.Get("/{jobId}/{jobGrowId}/{skillId}/", func(w http.ResponseWriter, r *http.Request) { + var level *int + if raw := r.URL.Query().Get("level"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil { + writeJSON(w, http.StatusBadRequest, model.Error(400, "level must be an integer", nil)) + return + } + level = &parsed + } + data, err := dep.OpenAPIService.SkillDetail(chi.URLParam(r, "jobId"), chi.URLParam(r, "jobGrowId"), chi.URLParam(r, "skillId"), level) + if err != nil { + writeJSON(w, http.StatusNotFound, model.Error(404, err.Error(), nil)) + return + } + writeJSON(w, http.StatusOK, model.Success(data)) + }) + }) + }) + + return r +} + +func requireAlterToken(w http.ResponseWriter, r *http.Request, tokens *service.TokenService) (service.AlterTokenPayload, bool) { + token := r.Header.Get("alter_token") + if token == "" { + writeJSON(w, http.StatusUnauthorized, model.Error(401, "missing alter_token header", nil)) + return service.AlterTokenPayload{}, false + } + payload, err := tokens.Parse(token) + if err != nil { + writeJSON(w, http.StatusUnauthorized, model.Error(401, "invalid alter_token header", nil)) + return service.AlterTokenPayload{}, false + } + return payload, true +} + +func writeNotImplemented(w http.ResponseWriter, err error) { + writeJSON(w, http.StatusNotImplemented, model.Error(501, err.Error(), nil)) +} + +func writeError(w http.ResponseWriter, status int, err error) { + writeJSON(w, status, model.Error(status, err.Error(), nil)) +} + +func writeJSON(w http.ResponseWriter, status int, payload model.Envelope) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, alter_token") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func splitCSV(value string) []string { + if value == "" { + return nil + } + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} diff --git a/backend/go/internal/model/response.go b/backend/go/internal/model/response.go new file mode 100644 index 00000000..d7529c16 --- /dev/null +++ b/backend/go/internal/model/response.go @@ -0,0 +1,15 @@ +package model + +type Envelope struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data"` +} + +func Success(data any) Envelope { + return Envelope{Code: 200, Message: "Success", Data: data} +} + +func Error(code int, message string, data any) Envelope { + return Envelope{Code: code, Message: message, Data: data} +} diff --git a/backend/go/internal/service/adventure.go b/backend/go/internal/service/adventure.go new file mode 100644 index 00000000..036c6bf7 --- /dev/null +++ b/backend/go/internal/service/adventure.go @@ -0,0 +1,137 @@ +package service + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +type AdventureService struct { + projectRoot string + cache *Cache + jobs []Job + tokenToJob map[string]string +} + +type Job struct { + Name string `json:"name"` + JobID string `json:"jobId"` + Title string `json:"title"` + Children []JobChild `json:"children"` +} + +type JobChild struct { + Name string `json:"name"` + JobGrowID string `json:"jobGrowId"` + Title string `json:"title"` +} + +type AdventureParent struct { + ID int `json:"id"` + Name string `json:"name"` + Title string `json:"title"` + Children []AdventureChild `json:"children"` +} + +type AdventureChild struct { + ID int `json:"id"` + Name string `json:"name"` + Title string `json:"title"` + Open bool `json:"open"` + Value string `json:"value"` +} + +func NewAdventureService(projectRoot string, cache *Cache) (*AdventureService, error) { + jobsPath := filepath.Join(projectRoot, "openapi", "jobs.json") + content, err := os.ReadFile(jobsPath) + if err != nil { + return nil, err + } + + var jobs []Job + if err := json.Unmarshal(content, &jobs); err != nil { + return nil, err + } + + svc := &AdventureService{ + projectRoot: projectRoot, + cache: cache, + jobs: jobs, + tokenToJob: make(map[string]string), + } + + for parentIndex, parent := range jobs { + for childIndex, child := range parent.Children { + token := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%02d%02d00", parentIndex, childIndex))) + svc.tokenToJob[token] = "GF." + parent.Name + "." + child.Name + } + } + + return svc, nil +} + +func (s *AdventureService) Jobs() []Job { + jobs := make([]Job, len(s.jobs)) + copy(jobs, s.jobs) + return jobs +} + +func (s *AdventureService) AdventureList() ([]AdventureParent, error) { + return GetOrLoad(s.cache, "adventure:list", 10*time.Minute, func() ([]AdventureParent, error) { + result := make([]AdventureParent, 0, len(s.jobs)) + for parentIndex, parent := range s.jobs { + children := make([]AdventureChild, 0, len(parent.Children)) + for childIndex, child := range parent.Children { + children = append(children, AdventureChild{ + ID: childIndex, + Name: child.Name, + Title: child.Title, + Open: true, + Value: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%02d%02d00", parentIndex, childIndex))), + }) + } + result = append(result, AdventureParent{ + ID: parentIndex, + Name: parent.Name, + Title: parent.Title, + Children: children, + }) + } + return result, nil + }) +} + +func (s *AdventureService) AlterByToken(token string) (string, error) { + alter, ok := s.tokenToJob[token] + if !ok { + return "", fmt.Errorf("invalid adventure token") + } + return alter, nil +} + +func (s *AdventureService) ResolveDirectories(jobIdentifier string, jobGrowIdentifier string) (string, string, error) { + for _, job := range s.jobs { + jobMatched := jobIdentifier == job.Name || jobIdentifier == job.JobID + if !jobMatched { + continue + } + for _, child := range job.Children { + if jobGrowIdentifier == child.Name || jobGrowIdentifier == child.JobGrowID { + return job.Name, child.Name, nil + } + } + } + return "", "", fmt.Errorf("unknown job path: %s/%s", jobIdentifier, jobGrowIdentifier) +} + +func (s *AdventureService) DirectoriesFromAlter(alter string) (string, string, error) { + parts := strings.Split(alter, ".") + if len(parts) != 3 || parts[0] != "GF" { + return "", "", fmt.Errorf("invalid alter value: %s", alter) + } + return parts[1], parts[2], nil +} diff --git a/backend/go/internal/service/cache.go b/backend/go/internal/service/cache.go new file mode 100644 index 00000000..0e0ce5d7 --- /dev/null +++ b/backend/go/internal/service/cache.go @@ -0,0 +1,44 @@ +package service + +import ( + "sync" + "time" +) + +type Cache struct { + mu sync.RWMutex + items map[string]cacheItem +} + +type cacheItem struct { + value any + expiresAt time.Time +} + +func NewCache() *Cache { + return &Cache{items: make(map[string]cacheItem)} +} + +func GetOrLoad[T any](cache *Cache, key string, ttl time.Duration, loader func() (T, error)) (T, error) { + var zero T + + cache.mu.RLock() + item, ok := cache.items[key] + cache.mu.RUnlock() + if ok && (item.expiresAt.IsZero() || item.expiresAt.After(time.Now())) { + if value, castOK := item.value.(T); castOK { + return value, nil + } + } + + value, err := loader() + if err != nil { + return zero, err + } + + cache.mu.Lock() + cache.items[key] = cacheItem{value: value, expiresAt: time.Now().Add(ttl)} + cache.mu.Unlock() + + return value, nil +} diff --git a/backend/go/internal/service/calc.go b/backend/go/internal/service/calc.go new file mode 100644 index 00000000..c5e981dd --- /dev/null +++ b/backend/go/internal/service/calc.go @@ -0,0 +1,34 @@ +package service + +import "errors" + +var ErrNotImplemented = errors.New("calculation domain has not been migrated to Go yet") + +type EquipmentCatalogRepository interface { + Catalog(alter string, equVersion string) (map[string]any, error) +} + +type CalcService struct { + factory CharacterFactory + equipment EquipmentCatalogRepository +} + +func NewCalcService(factory CharacterFactory, equipment EquipmentCatalogRepository) *CalcService { + return &CalcService{factory: factory, equipment: equipment} +} + +func (s *CalcService) CharacterInfo(alter string, equVersion string) (any, error) { + return s.factory.CharacterInfo(alter, equVersion) +} + +func (s *CalcService) Calculate(_ string, _ string, _ map[string]any) (any, error) { + return nil, ErrNotImplemented +} + +func (s *CalcService) SkillTree(_ string, _ string) (any, error) { + return nil, ErrNotImplemented +} + +func (s *CalcService) DNFHelper(_ string, _ string) (any, error) { + return nil, ErrNotImplemented +} diff --git a/backend/go/internal/service/character.go b/backend/go/internal/service/character.go new file mode 100644 index 00000000..64dc0151 --- /dev/null +++ b/backend/go/internal/service/character.go @@ -0,0 +1,21 @@ +package service + +import ( + "time" +) + +type CharacterService struct { + cache *Cache + factory CharacterFactory +} + +func NewCharacterService(cache *Cache, factory CharacterFactory) *CharacterService { + return &CharacterService{cache: cache, factory: factory} +} + +func (s *CharacterService) StaticInfo(alter string, equVersion string) (map[string]any, error) { + key := "character:static:" + alter + ":" + equVersion + return GetOrLoad(s.cache, key, 15*time.Minute, func() (map[string]any, error) { + return s.factory.CharacterInfo(alter, equVersion) + }) +} diff --git a/backend/go/internal/service/colg.go b/backend/go/internal/service/colg.go new file mode 100644 index 00000000..30dad321 --- /dev/null +++ b/backend/go/internal/service/colg.go @@ -0,0 +1,304 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "dcalc-go/internal/config" +) + +type ColgService struct { + cache *Cache + config config.Config + client *http.Client +} + +var regionMap = map[string]int{ + "跨1": 1, + "跨2": 2, + "跨3A": 3, + "跨3B": 4, + "跨4": 5, + "跨5": 6, + "跨6": 7, + "跨7": 8, +} + +func NewColgService(cache *Cache, cfg config.Config) *ColgService { + return &ColgService{ + cache: cache, + config: cfg, + client: &http.Client{Timeout: 12 * time.Second}, + } +} + +func (s *ColgService) RealTimeGold(ctx context.Context) ([]map[string]any, error) { + if s.config.ColgToken == "" { + return nil, fmt.Errorf("COLG_TOKEN is required") + } + + return GetOrLoad(s.cache, "colg:realtime", 30*time.Minute, func() ([]map[string]any, error) { + result := make([]map[string]any, 0, len(regionMap)) + now := time.Now().Format("2006-01-02") + for regionName, regionID := range regionMap { + data, err := s.requestTrendData(ctx, regionID, now, now) + if err != nil { + return nil, err + } + xValues, _ := data["x"].([]any) + yValues, _ := data["y"].([]any) + if len(xValues) == 0 || len(yValues) == 0 { + continue + } + result = append(result, map[string]any{ + "updataTime": formatBeijingTime(int64FromAny(xValues[len(xValues)-1])), + "region": regionName, + "goldPrice": float64FromAny(yValues[len(yValues)-1]), + }) + } + return result, nil + }) +} + +func (s *ColgService) HistoryGold(ctx context.Context, region int, timeRange int) (map[string]any, error) { + if s.config.ColgToken == "" { + return nil, fmt.Errorf("COLG_TOKEN is required") + } + if timeRange <= 0 { + timeRange = 7 + } + + regionName := "" + for name, id := range regionMap { + if id == region { + regionName = name + break + } + } + if regionName == "" { + return nil, fmt.Errorf("invalid region") + } + + endDate := time.Now() + startDate := endDate.AddDate(0, 0, -(timeRange - 1)) + trend, err := s.requestTrendData(ctx, region, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) + if err != nil { + return nil, err + } + + xValues, _ := trend["x"].([]any) + yValues, _ := trend["y"].([]any) + y1Values, _ := trend["y1"].([]any) + rows := make([]map[string]any, 0, len(xValues)) + minValue := 0.0 + maxValue := 0.0 + for index := range xValues { + avg := float64FromAny(yValues[index]) + zero := float64FromAny(y1Values[index]) + if index == 0 || avg < minValue { + minValue = avg + } + if zero < minValue { + minValue = zero + } + if index == 0 || avg > maxValue { + maxValue = avg + } + if zero > maxValue { + maxValue = zero + } + rows = append(rows, map[string]any{ + "date": strings.Split(formatBeijingTime(int64FromAny(xValues[index])), " ")[0], + "avg": avg, + "zero": zero, + }) + } + + title := fmt.Sprintf("DNF%s近%d天金币价格趋势分析", regionName, timeRange) + echarts := map[string]any{ + "title": map[string]any{"text": title, "left": "center"}, + "toolbox": map[string]any{"feature": map[string]any{"saveAsImage": map[string]any{}, "dataView": map[string]any{"readOnly": true}, "magicType": map[string]any{"type": []string{"line", "bar"}}}}, + "tooltip": map[string]any{"trigger": "axis", "axisPointer": map[string]any{"type": "cross", "label": map[string]any{"backgroundColor": "#6a7985"}}}, + "legend": map[string]any{"data": []string{"平均金价", "12时金价"}, "bottom": 0}, + "xAxis": map[string]any{"type": "category", "name": "日期", "boundaryGap": false, "data": extractField(rows, "date")}, + "yAxis": map[string]any{"type": "value", "name": "价格 (万金币/1人民币)", "min": floorToFive(minValue), "max": floorToFive(maxValue) + 5, "axisLabel": map[string]any{"formatter": "{value} 万金币"}}, + "series": []map[string]any{ + {"name": "平均金价", "type": "line", "smooth": true, "data": extractField(rows, "avg"), "markPoint": map[string]any{"data": []map[string]any{{"type": "max", "name": "最大值"}, {"type": "min", "name": "最小值"}}}}, + {"name": "12时金价", "type": "line", "smooth": true, "data": extractField(rows, "zero"), "markLine": map[string]any{"data": []map[string]any{{"type": "average", "name": "平均值"}}}}, + }, + } + + return map[string]any{"region": regionName, "data": rows, "echartsData": echarts}, nil +} + +func (s *ColgService) OfficialGold(ctx context.Context) ([]map[string]any, error) { + if s.config.HelperToken == "" || s.config.HelperID == "" { + return nil, fmt.Errorf("HELPER_TOKEN and HELPER_ID are required") + } + + return GetOrLoad(s.cache, "colg:official", 30*time.Minute, func() ([]map[string]any, error) { + servers := []string{"1", "2", "3A", "3B", "4", "5", "6", "7"} + result := make([]map[string]any, 0, len(servers)) + for _, server := range servers { + data, err := s.requestOfficialGold(ctx, server) + if err != nil { + return nil, err + } + result = append(result, data) + } + return result, nil + }) +} + +func (s *ColgService) requestTrendData(ctx context.Context, region int, startTime string, endTime string) (map[string]any, error) { + payload := map[string]any{ + "token": s.config.ColgToken, + "area": region, + "time_type": "other", + "start_time": startTime, + "end_time": endTime, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://poservice.colg.cn/api/v1/trend/getCoinTrendDataKol", bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var payloadResp map[string]any + if err := json.Unmarshal(content, &payloadResp); err != nil { + return nil, err + } + data, _ := payloadResp["data"].(map[string]any) + return data, nil +} + +func (s *ColgService) requestOfficialGold(ctx context.Context, server string) (map[string]any, error) { + form := url.Values{} + form.Set("r", "godRank/godDealRank") + form.Set("userId", s.config.HelperID) + form.Set("token", s.config.HelperToken) + form.Set("serverNo", server) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://dzhu.qq.com/zangyi/activity/app", strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var payloadResp map[string]any + if err := json.Unmarshal(content, &payloadResp); err != nil { + return nil, err + } + data, _ := payloadResp["data"].(map[string]any) + return map[string]any{ + "updataTime": time.Now().Format("2006-01-02 15:04:05"), + "region": "跨" + server, + "goldDealRank": extractPrices(data["godDealRank"]), + "goldTopDealRank": extractPrices(data["godTopDealRank"]), + }, nil +} + +func extractPrices(value any) []int { + rows, _ := value.([]any) + result := make([]int, 0, len(rows)) + for _, row := range rows { + rowMap, _ := row.(map[string]any) + result = append(result, intFromAnyString(rowMap["price"])) + } + return result +} + +func extractField(rows []map[string]any, key string) []any { + result := make([]any, 0, len(rows)) + for _, row := range rows { + result = append(result, row[key]) + } + return result +} + +func formatBeijingTime(ts int64) string { + return time.Unix(ts, 0).UTC().Add(8 * time.Hour).Format("2006-01-02 15:04:05") +} + +func floorToFive(value float64) float64 { + return float64(int(value/5) * 5) +} + +func float64FromAny(value any) float64 { + switch typed := value.(type) { + case float64: + return typed + case float32: + return float64(typed) + case int: + return float64(typed) + case int64: + return float64(typed) + case json.Number: + parsed, _ := typed.Float64() + return parsed + default: + return 0 + } +} + +func int64FromAny(value any) int64 { + switch typed := value.(type) { + case int64: + return typed + case int: + return int64(typed) + case float64: + return int64(typed) + case json.Number: + parsed, _ := typed.Int64() + return parsed + default: + return 0 + } +} + +func intFromAnyString(value any) int { + switch typed := value.(type) { + case string: + parsed, _ := strconv.Atoi(typed) + return parsed + default: + return int(float64FromAny(value)) + } +} diff --git a/backend/go/internal/service/equipment_catalog_sqlite.go b/backend/go/internal/service/equipment_catalog_sqlite.go new file mode 100644 index 00000000..6f204cd1 --- /dev/null +++ b/backend/go/internal/service/equipment_catalog_sqlite.go @@ -0,0 +1,295 @@ +package service + +import ( + "database/sql" + "fmt" + "path/filepath" + "strconv" + "strings" + + _ "modernc.org/sqlite" +) + +type SQLiteEquipmentCatalogRepository struct { + projectRoot string +} + +func NewSQLiteEquipmentCatalogRepository(projectRoot string) *SQLiteEquipmentCatalogRepository { + return &SQLiteEquipmentCatalogRepository{projectRoot: projectRoot} +} + +func (r *SQLiteEquipmentCatalogRepository) Catalog(_ string, equVersion string) (map[string]any, error) { + if equVersion == "" { + equVersion = "0" + } + + dbPath := filepath.Join(r.projectRoot, "dataFiles", fmt.Sprintf("dcalc_%s.db", equVersion)) + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + defer db.Close() + + equips, err := queryEquipRows(db, "equ") + if err != nil { + return nil, err + } + stones, err := queryEquipRows(db, "stone") + if err != nil { + return nil, err + } + suits, err := querySuitRows(db) + if err != nil { + return nil, err + } + enchants, err := queryEnchantRows(db, "enchant") + if err != nil { + return nil, err + } + emblems, err := queryEnchantRows(db, "emblem") + if err != nil { + return nil, err + } + jades, err := queryJadeRows(db) + if err != nil { + return nil, err + } + + return map[string]any{ + "equips": equips, + "suits": suits, + "stones": stones, + "enchants": enchants, + "emblems": emblems, + "jades": jades, + "avatar": map[string]any{}, + "options": map[string]any{}, + "sundry": []any{}, + }, nil +} + +func queryEquipRows(db *sql.DB, table string) ([]map[string]any, error) { + query := fmt.Sprintf("SELECT id,name,rarity,itemType,itemDetailType,Point,categorize,imageUrl,detail,bufferDetail,STR,INT,Vitality,Spirit,AtkP,AtkM,AtkI,SkillAttack,Attack,Buffer,suit FROM %s", table) + rows, err := db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make([]map[string]any, 0) + for rows.Next() { + var ( + id, name, rarity, itemType, itemDetailType, point, categorize, imageURL, detail, bufferDetail sql.NullString + strV, intV, vitV, spiV, atkPV, atkMV, atkIV, skillAttackV, attackV, bufferV, suitV sql.NullString + ) + if err := rows.Scan(&id, &name, &rarity, &itemType, &itemDetailType, &point, &categorize, &imageURL, &detail, &bufferDetail, &strV, &intV, &vitV, &spiV, &atkPV, &atkMV, &atkIV, &skillAttackV, &attackV, &bufferV, &suitV); err != nil { + return nil, err + } + + entry := map[string]any{ + "id": nullString(id), + "name": nullString(name), + "rarity": nullString(rarity), + "itemType": nullString(itemType), + "itemDetailType": nullString(itemDetailType), + "categorize": nullString(categorize), + "imageUrl": nullString(imageURL), + "detail": nullString(detail), + "bufferDetail": nullString(bufferDetail), + "Point": numberListOrDefault(nullString(point)), + "STR": numberListOrDefault(nullString(strV)), + "INT": numberListOrDefault(nullString(intV)), + "Vitality": numberListOrDefault(nullString(vitV)), + "Spirit": numberListOrDefault(nullString(spiV)), + "AtkP": numberListOrDefault(nullString(atkPV)), + "AtkM": numberListOrDefault(nullString(atkMV)), + "AtkI": numberListOrDefault(nullString(atkIV)), + "SkillAttack": numberListOrDefault(nullString(skillAttackV)), + "Attack": numberListOrDefault(nullString(attackV)), + "Buffer": numberListOrDefault(nullString(bufferV)), + "suit": suitListOrDefault(nullString(suitV)), + } + + maxAdaptation := 0 + for _, key := range []string{"Point", "STR", "INT", "Vitality", "Spirit", "AtkP", "AtkM", "AtkI", "SkillAttack", "Attack", "Buffer"} { + if values, ok := entry[key].([]float64); ok && len(values)-1 > maxAdaptation { + maxAdaptation = len(values) - 1 + } + } + entry["max_adaptation"] = maxAdaptation + result = append(result, entry) + } + return result, rows.Err() +} + +func querySuitRows(db *sql.DB) ([]map[string]any, error) { + rows, err := db.Query("SELECT id,suitId,suitName,rarity,name,point,level,count,SkillAttack,Attack,Buffer,imageUrl,value,bufferValue,fame FROM suit") + if err != nil { + return nil, err + } + defer rows.Close() + + result := make([]map[string]any, 0) + for rows.Next() { + var ( + id, suitID, point, level, count, fame sql.NullInt64 + suitName, rarity, name, imageURL, value, bufferValue sql.NullString + skillAttack, attack, buffer sql.NullFloat64 + ) + if err := rows.Scan(&id, &suitID, &suitName, &rarity, &name, &point, &level, &count, &skillAttack, &attack, &buffer, &imageURL, &value, &bufferValue, &fame); err != nil { + return nil, err + } + result = append(result, map[string]any{ + "id": nullInt64(id), + "suitId": nullInt64(suitID), + "suitName": nullString(suitName), + "rarity": nullString(rarity), + "name": nullString(name), + "point": nullInt64(point), + "level": nullInt64(level), + "count": nullInt64(count), + "SkillAttack": nullFloat64(skillAttack), + "Attack": nullFloat64(attack), + "Buffer": nullFloat64(buffer), + "imageUrl": nullString(imageURL), + "value": nullString(value), + "bufferValue": nullString(bufferValue), + "fame": nullInt64(fame), + }) + } + return result, rows.Err() +} + +func queryEnchantRows(db *sql.DB, table string) ([]map[string]any, error) { + query := fmt.Sprintf("SELECT id,detail,categorize,itemType,fame,rarity FROM %s", table) + rows, err := db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make([]map[string]any, 0) + for rows.Next() { + var id, fame sql.NullInt64 + var detail, categorize, itemType, rarity sql.NullString + if err := rows.Scan(&id, &detail, &categorize, &itemType, &fame, &rarity); err != nil { + return nil, err + } + result = append(result, map[string]any{ + "id": nullInt64(id), + "detail": nullString(detail), + "categorize": splitCSV(nullString(categorize)), + "position": splitCSV(nullString(itemType)), + "fame": nullInt64(fame), + "rarity": nullString(rarity), + }) + } + return result, rows.Err() +} + +func queryJadeRows(db *sql.DB) ([]map[string]any, error) { + rows, err := db.Query("SELECT id,name,min,max,unit,pre FROM jade") + if err != nil { + return nil, err + } + defer rows.Close() + + result := make([]map[string]any, 0) + for rows.Next() { + var id, minV, maxV sql.NullInt64 + var name, unit sql.NullString + var pre sql.NullFloat64 + if err := rows.Scan(&id, &name, &minV, &maxV, &unit, &pre); err != nil { + return nil, err + } + result = append(result, map[string]any{ + "id": nullInt64(id), + "name": nullString(name), + "min": nullInt64(minV), + "max": nullInt64(maxV), + "unit": nullString(unit), + "pre": nullFloat64(pre), + }) + } + return result, rows.Err() +} + +func numberListOrDefault(raw string) []float64 { + raw = strings.TrimSpace(raw) + if raw == "" { + return []float64{0} + } + parts := strings.Split(raw, ",") + result := make([]float64, 0, len(parts)) + for _, part := range parts { + value, err := strconv.ParseFloat(strings.TrimSpace(part), 64) + if err != nil { + result = append(result, 0) + continue + } + result = append(result, value) + } + if len(result) == 0 { + return []float64{0} + } + return result +} + +func suitListOrDefault(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return []string{} + } + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + value, err := strconv.ParseFloat(part, 64) + if err != nil { + result = append(result, part) + continue + } + result = append(result, strconv.Itoa(int(value))) + } + return result +} + +func splitCSV(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return []string{} + } + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + return result +} + +func nullString(value sql.NullString) string { + if !value.Valid { + return "" + } + return value.String +} + +func nullInt64(value sql.NullInt64) int64 { + if !value.Valid { + return 0 + } + return value.Int64 +} + +func nullFloat64(value sql.NullFloat64) float64 { + if !value.Valid { + return 0 + } + return value.Float64 +} diff --git a/backend/go/internal/service/openapi.go b/backend/go/internal/service/openapi.go new file mode 100644 index 00000000..3db1a75b --- /dev/null +++ b/backend/go/internal/service/openapi.go @@ -0,0 +1,264 @@ +package service + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +var placeholderPattern = regexp.MustCompile(`<(?:(?:int)|(?:float\d*))>`) +var filenameJobPrefix = regexp.MustCompile(`^\d+(?:-\d+){0,2}`) + +type OpenAPIService struct { + projectRoot string + cache *Cache + adventure *AdventureService +} + +func NewOpenAPIService(projectRoot string, cache *Cache, adventure *AdventureService) *OpenAPIService { + return &OpenAPIService{projectRoot: projectRoot, cache: cache, adventure: adventure} +} + +func (s *OpenAPIService) SearchSkills(skillName string) ([]map[string]any, error) { + skills, err := GetOrLoad(s.cache, "openapi:skills", 30*time.Minute, func() ([]map[string]any, error) { + var data []map[string]any + if err := s.readJSON(filepath.Join(s.projectRoot, "openapi", "data", "skills.json"), &data); err != nil { + return nil, err + } + return data, nil + }) + if err != nil { + return nil, err + } + + normalized := normalizeWhitespace(skillName) + if normalized == "" { + return skills, nil + } + + filtered := make([]map[string]any, 0) + for _, skill := range skills { + name, _ := skill["skillName"].(string) + if strings.Contains(normalizeWhitespace(name), normalized) { + filtered = append(filtered, skill) + } + } + return filtered, nil +} + +func (s *OpenAPIService) JobsInfo(jobName string) ([]map[string]string, error) { + jobName = strings.NewReplacer("(", "(", ")", ")").Replace(jobName) + result := make([]map[string]string, 0) + for _, job := range s.adventure.Jobs() { + for _, child := range job.Children { + if jobName == "" || strings.Contains(child.Title, jobName) || strings.Contains(child.Name, jobName) { + result = append(result, map[string]string{ + "jobId": job.Name, + "jobName": job.Title, + "jobGrowId": child.Name, + "jobGrowName": child.Title, + }) + } + } + } + return result, nil +} + +func (s *OpenAPIService) SkillsByJob(jobID string, jobGrowID string) (any, error) { + jobDir, jobGrowDir, err := s.adventure.ResolveDirectories(jobID, jobGrowID) + if err != nil { + return nil, err + } + + var data any + path := filepath.Join(s.projectRoot, "openapi", "data", jobDir, jobGrowDir, "cn", "skill_tree.json") + if err := s.readJSON(path, &data); err != nil { + return nil, err + } + return data, nil +} + +func (s *OpenAPIService) SkillDetail(jobID string, jobGrowID string, skillID string, level *int) (any, error) { + jobDir, jobGrowDir, err := s.adventure.ResolveDirectories(jobID, jobGrowID) + if err != nil { + return nil, err + } + + var skillInfo map[string]any + path := filepath.Join(s.projectRoot, "openapi", "data", jobDir, jobGrowDir, "cn", "skillDetail", skillID+".json") + if err := s.readJSON(path, &skillInfo); err != nil { + return nil, err + } + + if levelInfo, ok := skillInfo["levelInfo"].(map[string]any); ok { + if optionDesc, ok := levelInfo["optionDesc"].(string); ok { + levelInfo["optionDesc"] = replacePlaceholders(optionDesc) + } + } + + if level == nil { + return skillInfo, nil + } + + maxLevel := intFromAny(skillInfo["maxLevel"]) + bounded := *level + if bounded < 0 { + bounded = 0 + } + if maxLevel > 0 && bounded > maxLevel { + bounded = maxLevel + } + + levelInfo, ok := skillInfo["levelInfo"].(map[string]any) + if !ok { + return skillInfo, nil + } + rows, ok := levelInfo["rows"].([]any) + if !ok { + return skillInfo, nil + } + + for _, row := range rows { + rowMap, ok := row.(map[string]any) + if !ok || intFromAny(rowMap["level"]) != bounded { + continue + } + + for key, value := range rowMap { + levelInfo[key] = value + } + detail, _ := levelInfo["optionDesc"].(string) + if optionValue, ok := levelInfo["optionValue"].(map[string]any); ok { + for key, value := range optionValue { + detail = strings.ReplaceAll(detail, "{"+key+"}", fmt.Sprint(value)) + } + } + levelInfo["detail"] = detail + delete(levelInfo, "rows") + skillInfo["attribute"] = levelInfo + delete(skillInfo, "levelInfo") + break + } + + return skillInfo, nil +} + +func (s *OpenAPIService) SkillDetailByAlter(alter string, skillID string, level int) (any, error) { + jobDir, jobGrowDir, err := s.adventure.DirectoriesFromAlter(alter) + if err != nil { + return nil, err + } + return s.SkillDetail(jobDir, jobGrowDir, skillID, &level) +} + +func (s *OpenAPIService) SkillDataSummary(job string, skills []string, weapons []string) ([]map[string]any, error) { + job = strings.NewReplacer("(", "(", ")", ")").Replace(job) + entries, err := os.ReadDir(filepath.Join(s.projectRoot, "openapi", "summary")) + if err != nil { + return nil, err + } + + skillFilter := make(map[string]struct{}) + for _, skill := range skills { + if skill != "" { + skillFilter[skill] = struct{}{} + } + } + weaponFilter := make(map[string]struct{}) + for _, weapon := range weapons { + if weapon != "" { + weaponFilter[weapon] = struct{}{} + } + } + + result := make([]map[string]any, 0) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") || !strings.Contains(entry.Name(), job) { + continue + } + + parts := strings.Split(strings.TrimSuffix(entry.Name(), ".json"), "_") + weapon := "通用" + if len(parts) > 1 && parts[len(parts)-1] != "" { + weapon = parts[len(parts)-1] + } + if len(weaponFilter) > 0 { + if _, ok := weaponFilter[weapon]; !ok { + continue + } + } + + var rows []map[string]any + if err := s.readJSON(filepath.Join(s.projectRoot, "openapi", "summary", entry.Name()), &rows); err != nil { + return nil, err + } + + jobTitle := strings.TrimPrefix(parts[0], filenameJobPrefix.FindString(parts[0])) + for _, row := range rows { + if len(skillFilter) > 0 { + skillName, _ := row["技能名称"].(string) + if _, ok := skillFilter[skillName]; !ok { + continue + } + } + copied := cloneMap(row) + copied["武器类型"] = weapon + copied["职业"] = jobTitle + result = append(result, copied) + } + } + + return result, nil +} + +func (s *OpenAPIService) readJSON(path string, dest any) error { + content, err := os.ReadFile(path) + if err != nil { + return err + } + if err := json.Unmarshal(content, dest); err != nil { + return fmt.Errorf("unmarshal %s: %w", path, err) + } + return nil +} + +func replacePlaceholders(template string) string { + count := 0 + return placeholderPattern.ReplaceAllStringFunc(template, func(_ string) string { + count++ + return fmt.Sprintf("{value%d}", count) + }) +} + +func normalizeWhitespace(value string) string { + return strings.Join(strings.Fields(value), "") +} + +func intFromAny(value any) int { + switch typed := value.(type) { + case int: + return typed + case int32: + return int(typed) + case int64: + return int(typed) + case float64: + return int(typed) + case float32: + return int(typed) + default: + return 0 + } +} + +func cloneMap(source map[string]any) map[string]any { + cloned := make(map[string]any, len(source)) + for key, value := range source { + cloned[key] = value + } + return cloned +} diff --git a/backend/go/internal/service/python_adapter.go b/backend/go/internal/service/python_adapter.go new file mode 100644 index 00000000..70c9858f --- /dev/null +++ b/backend/go/internal/service/python_adapter.go @@ -0,0 +1,83 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "time" + + "dcalc-go/internal/config" +) + +type CharacterSkillMeta struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + LearnLv int `json:"learnLv"` + MaxLearnLv int `json:"maxLearnLv"` + HasVP bool `json:"hasVP"` + HasUP bool `json:"hasUP"` + UUID string `json:"uuid"` +} + +type CharacterFactory interface { + CharacterInfo(alter string, equVersion string) (map[string]any, error) + CharacterSkills(alter string, equVersion string) ([]CharacterSkillMeta, error) +} + +type PythonCharacterFactory struct { + pythonExec string + projectRoot string + scriptPath string +} + +func NewPythonCharacterFactory(cfg config.Config) *PythonCharacterFactory { + return &PythonCharacterFactory{ + pythonExec: cfg.PythonExec, + projectRoot: cfg.ProjectRoot, + scriptPath: filepath.Join(cfg.ProjectRoot, "go", "python_bridge.py"), + } +} + +func (p *PythonCharacterFactory) CharacterInfo(alter string, equVersion string) (map[string]any, error) { + result := map[string]any{} + if err := p.invoke("character-info", alter, equVersion, &result); err != nil { + return nil, err + } + return result, nil +} + +func (p *PythonCharacterFactory) CharacterSkills(alter string, equVersion string) ([]CharacterSkillMeta, error) { + var result []CharacterSkillMeta + if err := p.invoke("character-skills", alter, equVersion, &result); err != nil { + return nil, err + } + return result, nil +} + +func (p *PythonCharacterFactory) invoke(command string, alter string, equVersion string, out any) error { + if equVersion == "" { + equVersion = "0" + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, p.pythonExec, p.scriptPath, p.projectRoot, command, alter, equVersion) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("python bridge failed: %w, stdout=%s, stderr=%s", err, stdout.String(), stderr.String()) + } + + if err := json.Unmarshal(stdout.Bytes(), out); err != nil { + return fmt.Errorf("unmarshal python bridge output: %w, raw=%s", err, stdout.String()) + } + return nil +} diff --git a/backend/go/internal/service/skilltree.go b/backend/go/internal/service/skilltree.go new file mode 100644 index 00000000..6e9ff251 --- /dev/null +++ b/backend/go/internal/service/skilltree.go @@ -0,0 +1,376 @@ +package service + +import ( + "bytes" + "compress/zlib" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "unicode/utf16" +) + +type SkillTreeService struct { + adventure *AdventureService + factory CharacterFactory + indexToUID map[int][]string +} + +type decodedSkillCode struct { + Header skillCodeHeader + Skills []skillLearnItem + Evolutions []skillChoice + Enhancements []skillChoice +} + +type skillCodeHeader struct { + Name string + Level int + JobBytes [2]int +} + +type skillLearnItem struct { + SkillIndex int + Level int + SkillType int +} + +type skillChoice struct { + SkillIndex int + Type int +} + +type LearnedSkill struct { + Lv int `json:"lv"` + Up int `json:"up"` + VP int `json:"vp"` + Name string `json:"name"` +} + +type SkillTreeResult struct { + Name string `json:"name"` + Level int `json:"level"` + Job map[string]string `json:"job"` + Skills map[string]LearnedSkill `json:"skills"` +} + +var roleMap = map[int]int{ + 0: 0, + 1: 3, + 2: 4, + 3: 7, + 4: 8, + 5: 5, + 6: 10, + 7: 2, + 8: 6, + 9: 15, + 10: 15, + 11: 1, + 12: 11, + 13: 12, + 14: 9, + 15: 13, + 16: 14, +} + +func NewSkillTreeService(projectRoot string, adventure *AdventureService, factory CharacterFactory) (*SkillTreeService, error) { + indexToUID, err := loadSkillIndexMap(filepath.Join(projectRoot, "dataFiles", "skillMap.json")) + if err != nil { + return nil, err + } + return &SkillTreeService{adventure: adventure, factory: factory, indexToUID: indexToUID}, nil +} + +func (s *SkillTreeService) Parse(code string, equVersion string) (SkillTreeResult, error) { + decoded, err := decodeSkillCode(code) + if err != nil { + return SkillTreeResult{}, err + } + + alter, err := alterFromJobBytes(decoded.Header.JobBytes[0], decoded.Header.JobBytes[1], s.adventure) + if err != nil { + return SkillTreeResult{}, err + } + + skills, err := s.factory.CharacterSkills(alter, equVersion) + if err != nil { + return SkillTreeResult{}, err + } + + skillByUUID := make(map[string][]CharacterSkillMeta) + for _, skill := range skills { + skillByUUID[skill.UUID] = append(skillByUUID[skill.UUID], skill) + } + + indexCandidates := make(map[int][]CharacterSkillMeta) + for _, item := range decoded.Skills { + if _, exists := indexCandidates[item.SkillIndex]; exists { + continue + } + indexCandidates[item.SkillIndex] = s.buildCandidates(item.SkillIndex, skillByUUID) + } + + learned := make(map[string]LearnedSkill) + for _, item := range decoded.Skills { + candidates := indexCandidates[item.SkillIndex] + chosen := chooseSkillCandidate(candidates, item.SkillType) + if chosen == nil { + continue + } + current, exists := learned[chosen.ID] + if !exists || current.Lv < item.Level { + learned[chosen.ID] = LearnedSkill{Lv: item.Level, Up: 0, VP: 0, Name: chosen.Name} + } + } + + for _, evo := range decoded.Evolutions { + for _, skill := range indexCandidates[evo.SkillIndex] { + if !skill.HasVP { + continue + } + if learnedSkill, ok := learned[skill.ID]; ok { + learnedSkill.VP = evo.Type + learned[skill.ID] = learnedSkill + } + } + } + + for _, up := range decoded.Enhancements { + for _, skill := range indexCandidates[up.SkillIndex] { + if !skill.HasUP { + continue + } + if learnedSkill, ok := learned[skill.ID]; ok { + learnedSkill.Up = up.Type + learned[skill.ID] = learnedSkill + } + } + } + + for _, skill := range skills { + if _, exists := learned[skill.ID]; exists { + continue + } + level := 0 + if skill.LearnLv == 50 || skill.LearnLv == 85 || skill.Name == "基础精通" { + level = skill.MaxLearnLv + } + learned[skill.ID] = LearnedSkill{Lv: level, Up: 0, VP: 0, Name: skill.Name} + } + + return SkillTreeResult{ + Name: decoded.Header.Name, + Level: decoded.Header.Level, + Job: map[string]string{"class": alter}, + Skills: learned, + }, nil +} + +func (s *SkillTreeService) buildCandidates(index int, skillByUUID map[string][]CharacterSkillMeta) []CharacterSkillMeta { + uuids := s.indexToUID[index] + seen := make(map[string]struct{}) + candidates := make([]CharacterSkillMeta, 0) + for _, uuid := range uuids { + for _, skill := range skillByUUID[uuid] { + if _, exists := seen[skill.ID]; exists { + continue + } + seen[skill.ID] = struct{}{} + candidates = append(candidates, skill) + } + } + if len(candidates) > 1 { + filtered := make([]CharacterSkillMeta, 0) + for _, skill := range candidates { + if skill.LearnLv == 50 || skill.LearnLv == 85 { + continue + } + filtered = append(filtered, skill) + } + if len(filtered) > 0 { + return filtered + } + } + return candidates +} + +func chooseSkillCandidate(candidates []CharacterSkillMeta, skillType int) *CharacterSkillMeta { + if len(candidates) == 0 { + return nil + } + if len(candidates) == 1 { + return &candidates[0] + } + wantedType := "passive" + if skillType == 1 { + wantedType = "active" + } + for i := range candidates { + if candidates[i].Type == wantedType { + return &candidates[i] + } + } + return &candidates[0] +} + +func loadSkillIndexMap(path string) (map[int][]string, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + uuidToIndexes := map[string][]int{} + if err := json.Unmarshal(content, &uuidToIndexes); err != nil { + return nil, err + } + + indexToUUID := make(map[int][]string) + for uuid, indexes := range uuidToIndexes { + for _, idx := range indexes { + indexToUUID[idx] = append(indexToUUID[idx], uuid) + } + } + return indexToUUID, nil +} + +func alterFromJobBytes(roleByte int, jobByte int, adventure *AdventureService) (string, error) { + parent, ok := roleMap[roleByte] + if !ok { + return "", fmt.Errorf("unsupported role byte: %d", roleByte) + } + + jobID := jobByte - 1 + if roleByte == 9 { + jobID = 1 + } + if roleByte == 10 { + jobID = 0 + } + + token := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%02d%02d00", parent, jobID))) + return adventure.AlterByToken(token) +} + +func decodeSkillCode(encoded string) (decodedSkillCode, error) { + result := decodedSkillCode{} + + compressed, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return result, fmt.Errorf("decode base64: %w", err) + } + + r, err := zlib.NewReader(bytes.NewReader(compressed)) + if err != nil { + return result, fmt.Errorf("create zlib reader: %w", err) + } + defer r.Close() + + raw, err := io.ReadAll(r) + if err != nil { + return result, fmt.Errorf("read zlib stream: %w", err) + } + if len(raw) < 14 { + return result, fmt.Errorf("invalid skill code payload") + } + + nameLen := int(raw[13]) + if 14+nameLen > len(raw) { + return result, fmt.Errorf("invalid name length in skill code") + } + + result.Header = skillCodeHeader{ + Name: decodeUTF16LE(raw[14 : 14+nameLen]), + Level: int(raw[5]), + JobBytes: [2]int{int(raw[2]), int(raw[3])}, + } + + data := raw[14+nameLen:] + if len(data)%2 == 1 { + data = append(data, 0) + } + + uint16s := make([]uint16, 0, len(data)/2) + for i := 0; i < len(data); i += 2 { + uint16s = append(uint16s, binary.LittleEndian.Uint16(data[i:i+2])) + } + if len(uint16s) == 0 { + return result, nil + } + + high := make([]byte, len(uint16s)) + low := make([]byte, len(uint16s)) + for i, value := range uint16s { + high[i] = byte(value >> 8) + low[i] = byte(value & 0xFF) + } + + pairCount := int(high[0]) + for i := 0; i < pairCount; i++ { + base := 1 + i*2 + if base+1 >= len(high) { + break + } + result.Skills = append(result.Skills, skillLearnItem{ + SkillIndex: int(high[base]), + Level: int(high[base+1]), + SkillType: int(low[base+1]), + }) + } + + sectionStart := (1 + pairCount*2) * 2 + if sectionStart >= len(data) { + return result, nil + } + sec2 := data[sectionStart:] + if len(sec2) < 2 { + return result, nil + } + + offset := 1 + evoCount := int(sec2[offset]) + offset++ + for i := 0; i < evoCount; i++ { + if offset+3 > len(sec2) { + break + } + result.Evolutions = append(result.Evolutions, skillChoice{ + SkillIndex: int(sec2[offset]), + Type: int(sec2[offset+2]) + 1, + }) + offset += 3 + } + + if offset >= len(sec2) { + return result, nil + } + enhCount := int(sec2[offset]) + offset++ + for i := 0; i < enhCount; i++ { + if offset+3 > len(sec2) { + break + } + result.Enhancements = append(result.Enhancements, skillChoice{ + SkillIndex: int(sec2[offset]), + Type: int(sec2[offset+2]) + 1, + }) + offset += 3 + } + + return result, nil +} + +func decodeUTF16LE(value []byte) string { + if len(value)%2 == 1 { + value = append(value, 0) + } + words := make([]uint16, 0, len(value)/2) + for i := 0; i < len(value); i += 2 { + words = append(words, binary.LittleEndian.Uint16(value[i:i+2])) + } + return strings.TrimSpace(string(utf16.Decode(words))) +} diff --git a/backend/go/internal/service/token.go b/backend/go/internal/service/token.go new file mode 100644 index 00000000..d3df0753 --- /dev/null +++ b/backend/go/internal/service/token.go @@ -0,0 +1,62 @@ +package service + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "time" +) + +const tokenTTL = 6 * time.Hour + +type TokenService struct { + adventure *AdventureService +} + +type AlterTokenPayload struct { + Alter string `json:"alter"` + EquVersion string `json:"equVersion"` + Time int64 `json:"time"` +} + +func NewTokenService(adventure *AdventureService) *TokenService { + return &TokenService{adventure: adventure} +} + +func (s *TokenService) Create(alterToken string, equVersion string) (string, error) { + if equVersion == "" { + equVersion = "0" + } + + alter, err := s.adventure.AlterByToken(alterToken) + if err != nil { + return "", err + } + + payload := AlterTokenPayload{ + Alter: alter, + EquVersion: equVersion, + Time: time.Now().UnixMilli(), + } + content, err := json.Marshal(payload) + if err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(content), nil +} + +func (s *TokenService) Parse(token string) (AlterTokenPayload, error) { + var payload AlterTokenPayload + content, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return payload, fmt.Errorf("decode token: %w", err) + } + if err := json.Unmarshal(content, &payload); err != nil { + return payload, fmt.Errorf("unmarshal token: %w", err) + } + if payload.Time <= 0 || time.Since(time.UnixMilli(payload.Time)) > tokenTTL { + return AlterTokenPayload{}, fmt.Errorf("token expired") + } + return payload, nil +} diff --git a/backend/go/python_bridge.py b/backend/go/python_bridge.py new file mode 100644 index 00000000..7088324b --- /dev/null +++ b/backend/go/python_bridge.py @@ -0,0 +1,77 @@ +import json +import os +import sys + + +def _fail(message: str) -> None: + print(json.dumps({'error': message}, ensure_ascii=False)) + sys.exit(1) + + +def _parse_args() -> tuple[str, str, str, str]: + if len(sys.argv) < 5: + _fail('usage: python_bridge.py ') + project_root = sys.argv[1] + command = sys.argv[2] + alter = sys.argv[3] + equ_version = sys.argv[4] + return project_root, command, alter, equ_version + + +def _ensure_path(project_root: str) -> None: + if project_root not in sys.path: + sys.path.insert(0, project_root) + sys.argv[0] = os.path.join(project_root, 'main.py') + os.chdir(project_root) + + +def _load_character(alter: str, equ_version: str): + from core.basic.character import createCharacter + + character = createCharacter(alter, equ_version or '0') + if character is None: + raise RuntimeError(f'failed to create character for {alter}') + return character + + +def _character_info(alter: str, equ_version: str): + character = _load_character(alter, equ_version) + return character.getInfo() + + +def _character_skills(alter: str, equ_version: str): + info = _character_info(alter, equ_version) + skills = info.get('skills', []) + return [ + { + 'id': str(skill.get('id', '')), + 'name': skill.get('name', ''), + 'type': skill.get('type', ''), + 'learnLv': int(skill.get('learnLv', 0)), + 'maxLearnLv': int(skill.get('maxLearnLv', 0)), + 'hasVP': bool(skill.get('hasVP', False)), + 'hasUP': bool(skill.get('hasUP', False)), + 'uuid': skill.get('uuid', ''), + } + for skill in skills + ] + + +def main() -> None: + project_root, command, alter, equ_version = _parse_args() + _ensure_path(project_root) + + try: + if command == 'character-info': + data = _character_info(alter, equ_version) + elif command == 'character-skills': + data = _character_skills(alter, equ_version) + else: + _fail(f'unknown command: {command}') + print(json.dumps(data, ensure_ascii=False)) + except Exception as ex: + _fail(str(ex)) + + +if __name__ == '__main__': + main()