1065 行
35 KiB
Go
1065 行
35 KiB
Go
package modules
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"math/rand"
|
||
"time"
|
||
|
||
"github.com/heroiclabs/nakama-common/runtime"
|
||
|
||
"github.com/honghuang-game/server/config"
|
||
"github.com/honghuang-game/server/internal/db"
|
||
"github.com/honghuang-game/server/internal/domain"
|
||
)
|
||
|
||
var realmSvc *RealmService
|
||
|
||
// RegisterRealm 注册修炼/境界相关 RPC。
|
||
func RegisterRealm(initializer runtime.Initializer) error {
|
||
realmSvc = NewRealmService(db.NewPgxRealmStore(), config.Global)
|
||
|
||
if err := initializer.RegisterRpc("RealmService/GetRealmProgress", getRealmProgress); err != nil {
|
||
return err
|
||
}
|
||
if err := initializer.RegisterRpc("RealmService/Cultivate", cultivate); err != nil {
|
||
return err
|
||
}
|
||
if err := initializer.RegisterRpc("RealmService/AttemptBreakthrough", attemptBreakthrough); err != nil {
|
||
return err
|
||
}
|
||
if err := initializer.RegisterRpc("RealmService/StartTribulation", startTribulation); err != nil {
|
||
return err
|
||
}
|
||
if err := initializer.RegisterRpc("RealmService/GetTribulationResult", getTribulationResult); err != nil {
|
||
return err
|
||
}
|
||
if err := initializer.RegisterRpc("RealmService/WorldBreak", worldBreak); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RealmService 实现境界/渡劫/破界业务逻辑,便于单元测试 mock 存储层。
|
||
type RealmService struct {
|
||
store db.RealmStore
|
||
cfg *config.Config
|
||
randFloat func() float64
|
||
}
|
||
|
||
// NewRealmService 创建境界服务实例。
|
||
func NewRealmService(store db.RealmStore, cfg *config.Config) *RealmService {
|
||
return &RealmService{
|
||
store: store,
|
||
cfg: cfg,
|
||
randFloat: rand.Float64,
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 请求/响应结构
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type realmReq struct {
|
||
CharacterID string `json:"character_id"`
|
||
}
|
||
|
||
type cultivateReq struct {
|
||
CharacterID string `json:"character_id"`
|
||
StaminaAmount int32 `json:"stamina_amount"`
|
||
Consumables []materialCostReq `json:"consumables"`
|
||
}
|
||
|
||
type breakthroughReq struct {
|
||
CharacterID string `json:"character_id"`
|
||
TargetMinorRealm int32 `json:"target_minor_realm"`
|
||
Consumables []materialCostReq `json:"consumables"`
|
||
HelperIDs []string `json:"helper_ids"`
|
||
IdempotencyKey string `json:"idempotency_key"`
|
||
}
|
||
|
||
type tribulationReq struct {
|
||
CharacterID string `json:"character_id"`
|
||
TargetRealmTier int32 `json:"target_realm_tier"`
|
||
TribulationType string `json:"tribulation_type"`
|
||
Consumables []materialCostReq `json:"consumables"`
|
||
HelperIDs []string `json:"helper_ids"`
|
||
}
|
||
|
||
type worldBreakReq struct {
|
||
CharacterID string `json:"character_id"`
|
||
TargetWorldTier int32 `json:"target_world_tier"`
|
||
KeyItemInstanceID string `json:"key_item_instance_id"`
|
||
Confirm bool `json:"confirm"`
|
||
}
|
||
|
||
type materialCostReq struct {
|
||
InventoryID string `json:"inventory_id"`
|
||
Quantity int32 `json:"quantity"`
|
||
}
|
||
|
||
type realmPointData struct {
|
||
RealmTier int32 `json:"realm_tier"`
|
||
MinorRealm int32 `json:"minor_realm"`
|
||
}
|
||
|
||
type realmProgressData struct {
|
||
RealmTier int32 `json:"realm_tier"`
|
||
MinorRealm int32 `json:"minor_realm"`
|
||
RealmName string `json:"realm_name"`
|
||
Exp int64 `json:"exp"`
|
||
ExpToNext int64 `json:"exp_to_next"`
|
||
MaxUnlockedWorldTier int32 `json:"max_unlocked_world_tier"`
|
||
RealmStatus string `json:"realm_status"`
|
||
BreakthroughReady bool `json:"breakthrough_ready"`
|
||
TribulationPending bool `json:"tribulation_pending"`
|
||
StaminaCurrent int32 `json:"stamina_current,omitempty"`
|
||
StaminaMax int32 `json:"stamina_max,omitempty"`
|
||
}
|
||
|
||
type cultivateData struct {
|
||
RealmPointData realmPointData `json:"realm_point"`
|
||
ExpGained int64 `json:"exp_gained"`
|
||
ExpToNext int64 `json:"exp_to_next"`
|
||
BreakthroughReady bool `json:"breakthrough_ready"`
|
||
StaminaRemaining int32 `json:"stamina_remaining"`
|
||
EventTriggered string `json:"event_triggered"`
|
||
EventBonusExp int64 `json:"event_bonus_exp"`
|
||
}
|
||
|
||
type breakthroughData struct {
|
||
Success bool `json:"success"`
|
||
From realmPointData `json:"from"`
|
||
To realmPointData `json:"to"`
|
||
ExpConsumed int64 `json:"exp_consumed"`
|
||
RecordID string `json:"record_id"`
|
||
SpecialEvent string `json:"special_event"`
|
||
}
|
||
|
||
type tribulationData struct {
|
||
TribulationID string `json:"tribulation_id"`
|
||
Status string `json:"status"`
|
||
BaseSuccessRate float64 `json:"base_success_rate"`
|
||
ModifiedSuccessRate float64 `json:"modified_success_rate"`
|
||
EstimatedTicks int32 `json:"estimated_ticks"`
|
||
StartAt string `json:"start_at"`
|
||
Result string `json:"result"`
|
||
FromRealmTier int32 `json:"from_realm_tier"`
|
||
ToRealmTier int32 `json:"to_realm_tier"`
|
||
Penalties interface{} `json:"penalties"`
|
||
Drops interface{} `json:"drops"`
|
||
}
|
||
|
||
type worldBreakData struct {
|
||
Success bool `json:"success"`
|
||
FromWorldTier int32 `json:"from_world_tier"`
|
||
ToWorldTier int32 `json:"to_world_tier"`
|
||
FromRealmTier int32 `json:"from_realm_tier"`
|
||
ToRealmTier int32 `json:"to_realm_tier"`
|
||
BreakthroughRecordID string `json:"breakthrough_record_id"`
|
||
WorldEvent string `json:"world_event"`
|
||
RuinGenerated bool `json:"ruin_generated"`
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// RPC Handlers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func getRealmProgress(ctx context.Context, logger runtime.Logger, _db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
||
traceID := newTraceID()
|
||
var req realmReq
|
||
if err := json.Unmarshal([]byte(payload), &req); err != nil || req.CharacterID == "" {
|
||
return errResp(3001, "invalid payload", traceID)
|
||
}
|
||
resp, code, msg := realmSvc.GetRealmProgress(ctx, req.CharacterID)
|
||
if code != 0 {
|
||
return errResp(code, msg, traceID)
|
||
}
|
||
logger.Info("RealmService/GetRealmProgress: character_id=%s", req.CharacterID)
|
||
return okResp(resp, traceID)
|
||
}
|
||
|
||
func cultivate(ctx context.Context, logger runtime.Logger, _db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
||
traceID := newTraceID()
|
||
var req cultivateReq
|
||
if err := json.Unmarshal([]byte(payload), &req); err != nil || req.CharacterID == "" {
|
||
return errResp(3001, "invalid payload", traceID)
|
||
}
|
||
resp, code, msg := realmSvc.Cultivate(ctx, req)
|
||
if code != 0 {
|
||
return errResp(code, msg, traceID)
|
||
}
|
||
logger.Info("RealmService/Cultivate: character_id=%s stamina=%d", req.CharacterID, req.StaminaAmount)
|
||
return okResp(resp, traceID)
|
||
}
|
||
|
||
func attemptBreakthrough(ctx context.Context, logger runtime.Logger, _db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
||
traceID := newTraceID()
|
||
var req breakthroughReq
|
||
if err := json.Unmarshal([]byte(payload), &req); err != nil || req.CharacterID == "" {
|
||
return errResp(3001, "invalid payload", traceID)
|
||
}
|
||
resp, code, msg := realmSvc.AttemptBreakthrough(ctx, req)
|
||
if code != 0 {
|
||
return errResp(code, msg, traceID)
|
||
}
|
||
logger.Info("RealmService/AttemptBreakthrough: character_id=%s success=%v", req.CharacterID, resp.Success)
|
||
return okResp(resp, traceID)
|
||
}
|
||
|
||
func startTribulation(ctx context.Context, logger runtime.Logger, _db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
||
traceID := newTraceID()
|
||
var req tribulationReq
|
||
if err := json.Unmarshal([]byte(payload), &req); err != nil || req.CharacterID == "" {
|
||
return errResp(3007, "invalid payload", traceID)
|
||
}
|
||
resp, code, msg := realmSvc.StartTribulation(ctx, req)
|
||
if code != 0 {
|
||
return errResp(code, msg, traceID)
|
||
}
|
||
logger.Info("RealmService/StartTribulation: character_id=%s result=%s", req.CharacterID, resp.Result)
|
||
return okResp(resp, traceID)
|
||
}
|
||
|
||
func getTribulationResult(ctx context.Context, logger runtime.Logger, _db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
||
traceID := newTraceID()
|
||
var req tribulationReq
|
||
if err := json.Unmarshal([]byte(payload), &req); err != nil || req.CharacterID == "" {
|
||
return errResp(3007, "invalid payload", traceID)
|
||
}
|
||
resp, code, msg := realmSvc.GetTribulationResult(ctx, req.CharacterID)
|
||
if code != 0 {
|
||
return errResp(code, msg, traceID)
|
||
}
|
||
logger.Info("RealmService/GetTribulationResult: character_id=%s", req.CharacterID)
|
||
return okResp(resp, traceID)
|
||
}
|
||
|
||
func worldBreak(ctx context.Context, logger runtime.Logger, _db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
||
traceID := newTraceID()
|
||
var req worldBreakReq
|
||
if err := json.Unmarshal([]byte(payload), &req); err != nil || req.CharacterID == "" {
|
||
return errResp(3011, "invalid payload", traceID)
|
||
}
|
||
if !req.Confirm {
|
||
return errResp(3011, "confirmation required", traceID)
|
||
}
|
||
resp, code, msg := realmSvc.WorldBreak(ctx, req)
|
||
if code != 0 {
|
||
return errResp(code, msg, traceID)
|
||
}
|
||
logger.Info("RealmService/WorldBreak: character_id=%s to_world=%d", req.CharacterID, req.TargetWorldTier)
|
||
return okResp(resp, traceID)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 业务逻辑
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func (s *RealmService) getConfigFloat(key string, def float64) float64 {
|
||
if s.cfg == nil {
|
||
return def
|
||
}
|
||
return s.cfg.GetFloat64(key, def)
|
||
}
|
||
|
||
func (s *RealmService) getConfigInt(key string, def int) int {
|
||
if s.cfg == nil {
|
||
return def
|
||
}
|
||
return s.cfg.GetInt(key, def)
|
||
}
|
||
|
||
func (s *RealmService) expToNext(tier, minor int32) int64 {
|
||
key := domain.ExpConfigKey(tier, minor)
|
||
if s.cfg != nil {
|
||
if v := s.cfg.GetInt(key, 0); v > 0 {
|
||
return int64(v)
|
||
}
|
||
}
|
||
return domain.DefaultExpToNext(tier, minor)
|
||
}
|
||
|
||
func (s *RealmService) dailyStaminaMax(realmTier int32) int32 {
|
||
// 按 GDD-21 境界体力锚点
|
||
switch realmTier {
|
||
case 1:
|
||
return 120
|
||
case 2:
|
||
return 150
|
||
case 3:
|
||
return 180
|
||
case 4:
|
||
return 210
|
||
case 5:
|
||
return 240
|
||
case 6:
|
||
return 300
|
||
}
|
||
return 120
|
||
}
|
||
|
||
func (s *RealmService) expPerStamina(realmTier int32) int64 {
|
||
// 默认 1 体力 = 100 修为;可按 Nacos 热更
|
||
return int64(s.getConfigInt("realm.exp_per_stamina", 100))
|
||
}
|
||
|
||
func (s *RealmService) getStamina(baseStats map[string]interface{}) (current, max int32) {
|
||
max = 120
|
||
if v, ok := baseStats["stamina_max"]; ok {
|
||
max = int32(toFloat64(v))
|
||
}
|
||
if v, ok := baseStats["stamina_current"]; ok {
|
||
current = int32(toFloat64(v))
|
||
} else {
|
||
current = max
|
||
}
|
||
return
|
||
}
|
||
|
||
func (s *RealmService) setStamina(baseStats map[string]interface{}, current, max int32) {
|
||
baseStats["stamina_max"] = max
|
||
baseStats["stamina_current"] = current
|
||
}
|
||
|
||
func toFloat64(v interface{}) float64 {
|
||
switch n := v.(type) {
|
||
case float64:
|
||
return n
|
||
case float32:
|
||
return float64(n)
|
||
case int:
|
||
return float64(n)
|
||
case int32:
|
||
return float64(n)
|
||
case int64:
|
||
return float64(n)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// GetRealmProgress 查询角色当前境界、小境界、修为与状态。
|
||
func (s *RealmService) GetRealmProgress(ctx context.Context, characterID string) (realmProgressData, int, string) {
|
||
c, err := s.store.GetCharacter(ctx, characterID)
|
||
if err != nil {
|
||
return realmProgressData{}, 3001, "character not found"
|
||
}
|
||
cr, err := s.store.EnsureCharacterRealm(ctx, characterID, c.RealmTier, c.MinorRealm)
|
||
if err != nil {
|
||
return realmProgressData{}, 3001, "realm data missing"
|
||
}
|
||
expToNext := s.expToNext(c.RealmTier, c.MinorRealm)
|
||
staminaCurrent, staminaMax := s.getStamina(c.BaseStats)
|
||
return realmProgressData{
|
||
RealmTier: c.RealmTier,
|
||
MinorRealm: c.MinorRealm,
|
||
RealmName: domain.RealmFullName(c.RealmTier, c.MinorRealm),
|
||
Exp: cr.ExpInTier,
|
||
ExpToNext: expToNext,
|
||
MaxUnlockedWorldTier: c.WorldTier,
|
||
RealmStatus: c.RealmStatus,
|
||
BreakthroughReady: cr.ExpInTier >= expToNext && c.RealmStatus == domain.RealmStatusNormal,
|
||
TribulationPending: c.RealmStatus == domain.RealmStatusTribulationPending,
|
||
StaminaCurrent: staminaCurrent,
|
||
StaminaMax: staminaMax,
|
||
}, 0, ""
|
||
}
|
||
|
||
// Cultivate 消耗体力/资源增加修为,并概率触发修炼事件。
|
||
func (s *RealmService) Cultivate(ctx context.Context, req cultivateReq) (cultivateData, int, string) {
|
||
c, err := s.store.GetCharacter(ctx, req.CharacterID)
|
||
if err != nil {
|
||
return cultivateData{}, 3001, "character not found"
|
||
}
|
||
if c.Status != "active" {
|
||
return cultivateData{}, 3004, "character not active"
|
||
}
|
||
if c.RealmStatus == domain.RealmStatusTribulationPending {
|
||
return cultivateData{}, 3004, "tribulation pending"
|
||
}
|
||
|
||
staminaCurrent, staminaMax := s.getStamina(c.BaseStats)
|
||
if req.StaminaAmount <= 0 {
|
||
req.StaminaAmount = 1
|
||
}
|
||
if staminaCurrent < req.StaminaAmount {
|
||
return cultivateData{}, 3002, "insufficient stamina"
|
||
}
|
||
|
||
// 消耗道具(如有)
|
||
for _, m := range req.Consumables {
|
||
itemID, qty, err := s.store.GetInventoryItem(ctx, req.CharacterID, m.InventoryID)
|
||
if err != nil || qty < m.Quantity {
|
||
return cultivateData{}, 3002, fmt.Sprintf("consumable insufficient: %s", m.InventoryID)
|
||
}
|
||
if err := s.store.ConsumeInventoryItem(ctx, req.CharacterID, m.InventoryID, m.Quantity); err != nil {
|
||
return cultivateData{}, 3002, "failed to consume material"
|
||
}
|
||
_ = itemID
|
||
}
|
||
|
||
// 消耗体力
|
||
staminaCurrent -= req.StaminaAmount
|
||
s.setStamina(c.BaseStats, staminaCurrent, staminaMax)
|
||
if err := s.store.UpdateCharacterBaseStats(ctx, req.CharacterID, c.BaseStats); err != nil {
|
||
return cultivateData{}, 9002, "failed to update stamina"
|
||
}
|
||
|
||
cr, err := s.store.EnsureCharacterRealm(ctx, req.CharacterID, c.RealmTier, c.MinorRealm)
|
||
if err != nil {
|
||
return cultivateData{}, 3001, "realm data missing"
|
||
}
|
||
|
||
expToNext := s.expToNext(c.RealmTier, c.MinorRealm)
|
||
baseGain := int64(req.StaminaAmount) * s.expPerStamina(c.RealmTier)
|
||
bonusExp, event := s.applyCultivationEvent(baseGain, expToNext)
|
||
newExp := cr.ExpInTier + baseGain + bonusExp
|
||
if newExp > expToNext*2 {
|
||
newExp = expToNext * 2 // 防止极端溢出
|
||
}
|
||
|
||
realmStatus := c.RealmStatus
|
||
if newExp >= expToNext && realmStatus == domain.RealmStatusNormal {
|
||
realmStatus = domain.RealmStatusBreakthroughReady
|
||
}
|
||
if err := s.store.UpdateCharacterRealm(ctx, req.CharacterID, c.RealmTier, c.MinorRealm, newExp, realmStatus); err != nil {
|
||
return cultivateData{}, 9002, "failed to update realm"
|
||
}
|
||
|
||
return cultivateData{
|
||
RealmPointData: realmPointData{RealmTier: c.RealmTier, MinorRealm: c.MinorRealm},
|
||
ExpGained: baseGain + bonusExp,
|
||
ExpToNext: expToNext,
|
||
BreakthroughReady: realmStatus == domain.RealmStatusBreakthroughReady,
|
||
StaminaRemaining: staminaCurrent,
|
||
EventTriggered: event,
|
||
EventBonusExp: bonusExp,
|
||
}, 0, ""
|
||
}
|
||
|
||
func (s *RealmService) applyCultivationEvent(baseGain, expToNext int64) (int64, string) {
|
||
if expToNext <= 0 {
|
||
return 0, ""
|
||
}
|
||
chancePer10Pct := s.getConfigFloat("event.cultivation.trigger_chance_per_10pct", 0.05)
|
||
progressRatio := float64(baseGain) / float64(expToNext)
|
||
tens := int(math.Floor(progressRatio * 10))
|
||
if tens < 1 {
|
||
tens = 1
|
||
}
|
||
for i := 0; i < tens; i++ {
|
||
if s.randFloat() < chancePer10Pct {
|
||
eventRoll := s.randFloat()
|
||
switch {
|
||
case eventRoll < 0.4:
|
||
return int64(float64(expToNext) * 0.3), "小顿悟·功法进度+30%"
|
||
case eventRoll < 0.7:
|
||
return int64(float64(expToNext) * 0.5), "灵气异动·修为暴涨"
|
||
case eventRoll < 0.9:
|
||
return 0, "心魔来袭·SAN动荡"
|
||
default:
|
||
return 0, "奇遇NPC·待后续领取"
|
||
}
|
||
}
|
||
}
|
||
return 0, ""
|
||
}
|
||
|
||
// AttemptBreakthrough 小境界突破(层内初期→中期→圆满)。
|
||
func (s *RealmService) AttemptBreakthrough(ctx context.Context, req breakthroughReq) (breakthroughData, int, string) {
|
||
c, err := s.store.GetCharacter(ctx, req.CharacterID)
|
||
if err != nil {
|
||
return breakthroughData{}, 3001, "character not found"
|
||
}
|
||
if c.Status != "active" {
|
||
return breakthroughData{}, 3004, "character not active"
|
||
}
|
||
if c.RealmStatus == domain.RealmStatusTribulationPending {
|
||
return breakthroughData{}, 3004, "tribulation pending"
|
||
}
|
||
if req.TargetMinorRealm != c.MinorRealm+1 {
|
||
return breakthroughData{}, 3003, "invalid target minor realm"
|
||
}
|
||
if req.TargetMinorRealm > 3 {
|
||
return breakthroughData{}, 3003, "already at tier peak"
|
||
}
|
||
|
||
cr, err := s.store.EnsureCharacterRealm(ctx, req.CharacterID, c.RealmTier, c.MinorRealm)
|
||
if err != nil {
|
||
return breakthroughData{}, 3001, "realm data missing"
|
||
}
|
||
expToNext := s.expToNext(c.RealmTier, c.MinorRealm)
|
||
if cr.ExpInTier < expToNext {
|
||
return breakthroughData{}, 3002, "exp insufficient"
|
||
}
|
||
|
||
// 消耗材料
|
||
if err := s.consumeMaterials(ctx, req.CharacterID, req.Consumables); err != nil {
|
||
return breakthroughData{}, 3002, err.Error()
|
||
}
|
||
|
||
// 护法者基础校验
|
||
helpers := req.HelperIDs
|
||
if len(helpers) > 0 {
|
||
count, err := s.store.CountCharacters(ctx, helpers)
|
||
if err != nil || count != len(helpers) {
|
||
return breakthroughData{}, 3005, "helper condition not met"
|
||
}
|
||
}
|
||
|
||
successRate := s.calcBreakthroughRate(c, c.RealmTier, false, helpers)
|
||
rolled := s.randFloat()
|
||
success := rolled < successRate
|
||
|
||
from := realmPointData{RealmTier: c.RealmTier, MinorRealm: c.MinorRealm}
|
||
to := from
|
||
specialEvent := ""
|
||
if success {
|
||
to.MinorRealm = req.TargetMinorRealm
|
||
if err := s.store.UpdateCharacterRealm(ctx, req.CharacterID, to.RealmTier, to.MinorRealm, 0, domain.RealmStatusNormal); err != nil {
|
||
return breakthroughData{}, 9002, "failed to update realm"
|
||
}
|
||
specialEvent = "突破成功"
|
||
} else {
|
||
// 小境界突破失败不跨层掉落,但损失部分修为
|
||
penaltyExp := cr.ExpInTier / 10
|
||
newExp := cr.ExpInTier - penaltyExp
|
||
if newExp < 0 {
|
||
newExp = 0
|
||
}
|
||
if err := s.store.UpdateCharacterRealm(ctx, req.CharacterID, c.RealmTier, c.MinorRealm, newExp, domain.RealmStatusNormal); err != nil {
|
||
return breakthroughData{}, 9002, "failed to update realm"
|
||
}
|
||
specialEvent = "突破失败·修为受损"
|
||
}
|
||
|
||
rec := &db.BreakthroughRecord{
|
||
CharacterID: req.CharacterID,
|
||
FromRealmTier: from.RealmTier,
|
||
ToRealmTier: to.RealmTier,
|
||
FromMinorRealm: from.MinorRealm,
|
||
ToMinorRealm: to.MinorRealm,
|
||
IsSuccess: success,
|
||
IsBreakWorldBarrier: false,
|
||
SourceWorldTier: c.WorldTier,
|
||
TargetWorldTier: c.WorldTier,
|
||
}
|
||
if err := s.store.InsertBreakthroughRecord(ctx, rec); err != nil {
|
||
return breakthroughData{}, 9002, "failed to record breakthrough"
|
||
}
|
||
|
||
return breakthroughData{
|
||
Success: success,
|
||
From: from,
|
||
To: to,
|
||
ExpConsumed: expToNext,
|
||
RecordID: "", // record id not returned by current schema insert; left empty intentionally
|
||
SpecialEvent: specialEvent,
|
||
}, 0, ""
|
||
}
|
||
|
||
func (s *RealmService) consumeMaterials(ctx context.Context, characterID string, materials []materialCostReq) error {
|
||
for _, m := range materials {
|
||
if m.InventoryID == "" || m.Quantity <= 0 {
|
||
continue
|
||
}
|
||
itemID, qty, err := s.store.GetInventoryItem(ctx, characterID, m.InventoryID)
|
||
if err != nil || qty < m.Quantity {
|
||
return fmt.Errorf("material insufficient: %s", m.InventoryID)
|
||
}
|
||
has, err := s.store.HasInventoryItem(ctx, characterID, m.InventoryID, itemID, m.Quantity)
|
||
if err != nil || !has {
|
||
return fmt.Errorf("material insufficient: %s", m.InventoryID)
|
||
}
|
||
if err := s.store.ConsumeInventoryItem(ctx, characterID, m.InventoryID, m.Quantity); err != nil {
|
||
return fmt.Errorf("failed to consume material: %s", m.InventoryID)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *RealmService) calcBreakthroughRate(c *db.Character, tier int32, isBig bool, helpers []string) float64 {
|
||
base := domain.DefaultTribulationBaseRate(tier, isBig)
|
||
base = s.getConfigFloat(domain.TribulationBaseRateKey(tier, isBig), base)
|
||
rate := base
|
||
|
||
// 中期→圆满加成
|
||
if !isBig && c.MinorRealm == 2 {
|
||
rate += s.getConfigFloat("tribulation.mid_to_full_bonus", 0.02)
|
||
}
|
||
// 圆满→下境初期惩罚(仅大境界渡劫)
|
||
if isBig {
|
||
rate += s.getConfigFloat("tribulation.full_to_next_penalty", -0.03)
|
||
}
|
||
// 罪孽惩罚
|
||
sinPenalty := math.Min(0.4, float64(c.CrimeScore)/5000.0*0.4)
|
||
rate -= sinPenalty
|
||
// 天道值减免
|
||
heavenMitigation := math.Min(0.05, float64(c.HeavenlyValue)/50.0*0.01)
|
||
rate += heavenMitigation
|
||
// 护法者(每位+2%,上限10%)
|
||
helperBonus := float64(len(helpers)) * 0.02
|
||
if helperBonus > 0.10 {
|
||
helperBonus = 0.10
|
||
}
|
||
rate += helperBonus
|
||
|
||
cap := s.getConfigFloat("tribulation.success_cap", 0.95)
|
||
floor := s.getConfigFloat("tribulation.success_floor", 0.05)
|
||
if rate > cap {
|
||
rate = cap
|
||
}
|
||
if rate < floor {
|
||
rate = floor
|
||
}
|
||
return rate
|
||
}
|
||
|
||
// StartTribulation 大境界渡劫。当前版本同步结算并返回 completed 状态。
|
||
func (s *RealmService) StartTribulation(ctx context.Context, req tribulationReq) (tribulationData, int, string) {
|
||
c, err := s.store.GetCharacter(ctx, req.CharacterID)
|
||
if err != nil {
|
||
return tribulationData{}, 3007, "character not found"
|
||
}
|
||
if c.Status != "active" {
|
||
return tribulationData{}, 3007, "character not active"
|
||
}
|
||
if c.RealmStatus == domain.RealmStatusTribulationPending {
|
||
return tribulationData{}, 3009, "already in tribulation"
|
||
}
|
||
if c.MinorRealm != 3 {
|
||
return tribulationData{}, 3007, "not at minor realm peak"
|
||
}
|
||
if req.TargetRealmTier != c.RealmTier+1 {
|
||
return tribulationData{}, 3008, "invalid target realm tier"
|
||
}
|
||
minSAN := int32(s.getConfigInt("tribulation.min_san", 30))
|
||
if c.SanCurrent < minSAN {
|
||
return tribulationData{}, 3010, "SAN too low"
|
||
}
|
||
|
||
// 消耗材料
|
||
if err := s.consumeMaterials(ctx, req.CharacterID, req.Consumables); err != nil {
|
||
return tribulationData{}, 3007, err.Error()
|
||
}
|
||
|
||
helpers := req.HelperIDs
|
||
if len(helpers) > 0 {
|
||
count, err := s.store.CountCharacters(ctx, helpers)
|
||
if err != nil || count != len(helpers) {
|
||
return tribulationData{}, 3005, "helper condition not met"
|
||
}
|
||
}
|
||
|
||
tribType := req.TribulationType
|
||
if tribType == "" {
|
||
tribType = s.determineTribulationType(c)
|
||
}
|
||
|
||
successRate := s.calcBreakthroughRate(c, c.RealmTier, true, helpers)
|
||
recordID, err := s.store.InsertTribulationRecord(ctx, &db.TribulationRecord{
|
||
CharacterID: req.CharacterID,
|
||
RealmTier: req.TargetRealmTier,
|
||
MinorRealm: 1,
|
||
TribulationType: tribType,
|
||
BaseSuccessRate: domain.DefaultTribulationBaseRate(c.RealmTier, true),
|
||
ModifiedSuccessRate: successRate,
|
||
HelperIDs: helpers,
|
||
Result: domain.TribulationResultPending,
|
||
Penalties: map[string]interface{}{},
|
||
DropsOnSuccess: []map[string]interface{}{},
|
||
SanSnapshot: c.SanCurrent,
|
||
})
|
||
if err != nil {
|
||
return tribulationData{}, 9002, "failed to create tribulation record"
|
||
}
|
||
|
||
// 进入渡劫状态
|
||
if err := s.store.UpdateCharacterRealm(ctx, req.CharacterID, c.RealmTier, c.MinorRealm, 0, domain.RealmStatusTribulationPending); err != nil {
|
||
return tribulationData{}, 9002, "failed to set tribulation status"
|
||
}
|
||
|
||
// 同步结算(MVP 简化;生产环境可改为异步任务)
|
||
result, penalties, drops := s.resolveTribulation(c, req.TargetRealmTier, tribType)
|
||
if err := s.store.UpdateTribulationRecord(ctx, recordID, result, penalties, drops); err != nil {
|
||
return tribulationData{}, 9002, "failed to update tribulation record"
|
||
}
|
||
|
||
fromRealm := c.RealmTier
|
||
toRealm := c.RealmTier
|
||
toMinor := c.MinorRealm
|
||
realmStatus := domain.RealmStatusNormal
|
||
|
||
switch result {
|
||
case domain.TribulationResultSuccess:
|
||
toRealm = req.TargetRealmTier
|
||
toMinor = 1
|
||
if err := s.store.UpdateCharacterWorldAndRealm(ctx, req.CharacterID, c.WorldTier, toRealm, toMinor, realmStatus); err != nil {
|
||
return tribulationData{}, 9002, "failed to advance realm"
|
||
}
|
||
case domain.TribulationResultFail:
|
||
// 层内回退:圆满→中期
|
||
toMinor = 2
|
||
if err := s.store.UpdateCharacterRealm(ctx, req.CharacterID, fromRealm, toMinor, 0, realmStatus); err != nil {
|
||
return tribulationData{}, 9002, "failed to setback realm"
|
||
}
|
||
case domain.TribulationResultBacklash:
|
||
// 跨层回退或严重受挫
|
||
if pType, ok := penalties["type"].(string); ok && pType == "drop_cross_tier" && fromRealm > 1 {
|
||
toRealm = fromRealm - 1
|
||
toMinor = 1
|
||
if err := s.store.UpdateCharacterWorldAndRealm(ctx, req.CharacterID, c.WorldTier, toRealm, toMinor, realmStatus); err != nil {
|
||
return tribulationData{}, 9002, "failed to setback realm"
|
||
}
|
||
} else {
|
||
toMinor = 2
|
||
if err := s.store.UpdateCharacterRealm(ctx, req.CharacterID, fromRealm, toMinor, 0, realmStatus); err != nil {
|
||
return tribulationData{}, 9002, "failed to setback realm"
|
||
}
|
||
}
|
||
case domain.TribulationResultDeath:
|
||
// 角色进入死亡状态
|
||
if err := s.store.UpdateCharacterStatus(ctx, req.CharacterID, "dead"); err != nil {
|
||
return tribulationData{}, 9002, "failed to set dead status"
|
||
}
|
||
}
|
||
|
||
estimatedTicks := int32(120)
|
||
if s.cfg != nil {
|
||
estimatedTicks = int32(s.cfg.GetInt("tribulation.estimated_ticks", 120))
|
||
}
|
||
|
||
return tribulationData{
|
||
TribulationID: recordID,
|
||
Status: "completed",
|
||
BaseSuccessRate: domain.DefaultTribulationBaseRate(fromRealm, true),
|
||
ModifiedSuccessRate: successRate,
|
||
EstimatedTicks: estimatedTicks,
|
||
StartAt: time.Now().UTC().Format(time.RFC3339),
|
||
Result: result,
|
||
FromRealmTier: fromRealm,
|
||
ToRealmTier: toRealm,
|
||
Penalties: penalties,
|
||
Drops: drops,
|
||
}, 0, ""
|
||
}
|
||
|
||
func (s *RealmService) determineTribulationType(c *db.Character) string {
|
||
if c.SanCurrent < 30 {
|
||
return domain.TribulationTypeHeartDevil
|
||
}
|
||
if c.CrimeScore > 500 {
|
||
return domain.TribulationTypeQiDeviation
|
||
}
|
||
if c.RealmTier >= 6 {
|
||
return domain.TribulationTypeHeti
|
||
}
|
||
return domain.TribulationTypeNormal
|
||
}
|
||
|
||
func (s *RealmService) resolveTribulation(c *db.Character, targetTier int32, tribType string) (string, map[string]interface{}, []map[string]interface{}) {
|
||
successRate := s.calcBreakthroughRate(c, c.RealmTier, true, nil)
|
||
if s.randFloat() < successRate {
|
||
drops := s.generateTribulationDrops(targetTier)
|
||
return domain.TribulationResultSuccess, map[string]interface{}{"type": "none"}, drops
|
||
}
|
||
|
||
// 失败惩罚
|
||
result, penalties := s.resolveTribulationFailure(c, c.RealmTier)
|
||
return result, penalties, nil
|
||
}
|
||
|
||
func (s *RealmService) resolveTribulationFailure(c *db.Character, tier int32) (string, map[string]interface{}) {
|
||
dropWithin := s.getConfigFloat("tribulation.drop.l2_within", 0.70)
|
||
dropCross := s.getConfigFloat("tribulation.drop.l2_cross", 0.20)
|
||
frustration := s.getConfigFloat("tribulation.drop.frustration", 0.12)
|
||
|
||
if tier >= 4 {
|
||
dropCross = s.getConfigFloat("tribulation.drop.l4_cross_major", 0.30)
|
||
remaining := 1.0 - dropCross - frustration
|
||
if remaining < 0 {
|
||
remaining = 0
|
||
}
|
||
dropWithin = remaining
|
||
}
|
||
|
||
roll := s.randFloat()
|
||
thunderDamage := s.calcTribulationDamage(tier, "thunder")
|
||
fireDamage := s.calcTribulationDamage(tier, "fire")
|
||
|
||
if roll < dropWithin {
|
||
return domain.TribulationResultFail, map[string]interface{}{
|
||
"type": "drop_within_tier",
|
||
"minor_realm_loss": 1,
|
||
"exp_loss_pct": 0.5,
|
||
"thunder_damage": thunderDamage,
|
||
"fire_damage": fireDamage,
|
||
}
|
||
}
|
||
if roll < dropWithin+dropCross {
|
||
return domain.TribulationResultBacklash, map[string]interface{}{
|
||
"type": "drop_cross_tier",
|
||
"realm_tier_loss": 1,
|
||
"minor_realm_set": 1,
|
||
"exp_loss_pct": 0.8,
|
||
"thunder_damage": thunderDamage,
|
||
"fire_damage": fireDamage,
|
||
"san_loss": 15,
|
||
}
|
||
}
|
||
return domain.TribulationResultBacklash, map[string]interface{}{
|
||
"type": "frustration",
|
||
"san_loss": 10,
|
||
"exp_loss_pct": 0.3,
|
||
"thunder_damage": thunderDamage,
|
||
"fire_damage": fireDamage,
|
||
}
|
||
}
|
||
|
||
func (s *RealmService) calcTribulationDamage(tier int32, dmgType string) float64 {
|
||
if dmgType == "thunder" {
|
||
base := s.getConfigFloat("tribulation.thunder.base_pct", 0.115)
|
||
return base * domain.ThunderLayerCoef(tier)
|
||
}
|
||
base := s.getConfigFloat("tribulation.fire.base_pct", 0.045)
|
||
return base * domain.FireLayerCoef(tier)
|
||
}
|
||
|
||
func (s *RealmService) generateTribulationDrops(targetTier int32) []map[string]interface{} {
|
||
// 按目标境界生成天材地宝/心法感悟
|
||
drops := []map[string]interface{}{
|
||
{"item_id": fmt.Sprintf("heavenly_dew_t%d", targetTier), "quantity": 1},
|
||
}
|
||
if s.randFloat() < 0.3 {
|
||
drops = append(drops, map[string]interface{}{"item_id": "insight_fragment", "quantity": 1})
|
||
}
|
||
return drops
|
||
}
|
||
|
||
// GetTribulationResult 查询最近一次渡劫记录。
|
||
func (s *RealmService) GetTribulationResult(ctx context.Context, characterID string) (tribulationData, int, string) {
|
||
rec, err := s.store.GetLatestTribulation(ctx, characterID)
|
||
if err != nil {
|
||
return tribulationData{}, 3007, "no tribulation record"
|
||
}
|
||
status := "completed"
|
||
if rec.Result == domain.TribulationResultPending {
|
||
status = "in_progress"
|
||
}
|
||
return tribulationData{
|
||
TribulationID: rec.ID,
|
||
Status: status,
|
||
BaseSuccessRate: rec.BaseSuccessRate,
|
||
ModifiedSuccessRate: rec.ModifiedSuccessRate,
|
||
EstimatedTicks: 0,
|
||
StartAt: rec.CreatedAt.Format(time.RFC3339),
|
||
Result: rec.Result,
|
||
FromRealmTier: rec.RealmTier - 1,
|
||
ToRealmTier: rec.RealmTier,
|
||
Penalties: rec.Penalties,
|
||
Drops: rec.DropsOnSuccess,
|
||
}, 0, ""
|
||
}
|
||
|
||
// WorldBreak 破界/晋级到下一世界层级。
|
||
func (s *RealmService) WorldBreak(ctx context.Context, req worldBreakReq) (worldBreakData, int, string) {
|
||
c, err := s.store.GetCharacter(ctx, req.CharacterID)
|
||
if err != nil {
|
||
return worldBreakData{}, 3011, "character not found"
|
||
}
|
||
if c.Status != "active" {
|
||
return worldBreakData{}, 3014, "character not active"
|
||
}
|
||
if c.RealmStatus == domain.RealmStatusTribulationPending {
|
||
return worldBreakData{}, 3014, "tribulation pending"
|
||
}
|
||
if req.TargetWorldTier != c.WorldTier+1 {
|
||
return worldBreakData{}, 3011, "target world tier not unlocked"
|
||
}
|
||
// 破界要求:境界达到目标层级圆满,或已超越
|
||
if !(c.RealmTier >= req.TargetWorldTier && (c.MinorRealm == 3 || c.RealmTier > req.TargetWorldTier)) {
|
||
return worldBreakData{}, 3013, "realm not perfected"
|
||
}
|
||
|
||
// 首次 1→2 可不填;后续需要破界钥
|
||
if req.TargetWorldTier > 2 {
|
||
keyItemID := s.getConfigString("realm.world_break_key_item_id", "world_break_key")
|
||
if req.KeyItemInstanceID == "" {
|
||
return worldBreakData{}, 3012, "missing world break key"
|
||
}
|
||
has, err := s.store.HasInventoryItem(ctx, req.CharacterID, req.KeyItemInstanceID, keyItemID, 1)
|
||
if err != nil || !has {
|
||
return worldBreakData{}, 3012, "missing world break key"
|
||
}
|
||
if err := s.store.ConsumeInventoryItem(ctx, req.CharacterID, req.KeyItemInstanceID, 1); err != nil {
|
||
return worldBreakData{}, 3012, "failed to consume key"
|
||
}
|
||
}
|
||
|
||
// 跨层携带限制
|
||
if err := s.checkCarryLimit(ctx, c, req.TargetWorldTier); err != nil {
|
||
return worldBreakData{}, 3015, err.Error()
|
||
}
|
||
|
||
// 应用携带限制:低阶货币超cap部分被系统回收(写审计)
|
||
if err := s.applyCarryLimit(ctx, c, req.TargetWorldTier); err != nil {
|
||
return worldBreakData{}, 3015, err.Error()
|
||
}
|
||
|
||
// 更新世界层级
|
||
toRealmTier := c.RealmTier
|
||
toMinorRealm := c.MinorRealm
|
||
if err := s.store.UpdateCharacterWorldAndRealm(ctx, req.CharacterID, req.TargetWorldTier, toRealmTier, toMinorRealm, domain.RealmStatusNormal); err != nil {
|
||
return worldBreakData{}, 9002, "failed to break world"
|
||
}
|
||
|
||
// 是否生成破界遗迹
|
||
ruinGenerated := s.randFloat() < s.ruinSpawnRate(req.TargetWorldTier)
|
||
|
||
rec := &db.BreakthroughRecord{
|
||
CharacterID: req.CharacterID,
|
||
FromRealmTier: c.RealmTier,
|
||
ToRealmTier: toRealmTier,
|
||
FromMinorRealm: c.MinorRealm,
|
||
ToMinorRealm: toMinorRealm,
|
||
IsSuccess: true,
|
||
IsBreakWorldBarrier: true,
|
||
SourceWorldTier: c.WorldTier,
|
||
TargetWorldTier: req.TargetWorldTier,
|
||
}
|
||
if err := s.store.InsertBreakthroughRecord(ctx, rec); err != nil {
|
||
return worldBreakData{}, 9002, "failed to record world break"
|
||
}
|
||
|
||
worldEvent := fmt.Sprintf("破界·%s已解锁", s.worldLayerName(req.TargetWorldTier))
|
||
if ruinGenerated {
|
||
worldEvent += "·遗迹显化"
|
||
}
|
||
|
||
return worldBreakData{
|
||
Success: true,
|
||
FromWorldTier: c.WorldTier,
|
||
ToWorldTier: req.TargetWorldTier,
|
||
FromRealmTier: c.RealmTier,
|
||
ToRealmTier: toRealmTier,
|
||
BreakthroughRecordID: "",
|
||
WorldEvent: worldEvent,
|
||
RuinGenerated: ruinGenerated,
|
||
}, 0, ""
|
||
}
|
||
|
||
func (s *RealmService) getConfigString(key, def string) string {
|
||
if s.cfg == nil {
|
||
return def
|
||
}
|
||
return s.cfg.GetString(key, def)
|
||
}
|
||
|
||
func (s *RealmService) worldLayerName(tier int32) string {
|
||
switch tier {
|
||
case 1:
|
||
return "种族出生地"
|
||
case 2:
|
||
return "洪荒主陆"
|
||
case 3:
|
||
return "洪荒腹地"
|
||
case 4:
|
||
return "太古秘境"
|
||
case 5:
|
||
return "混沌之渊·化神域"
|
||
case 6:
|
||
return "混沌之渊·合体域"
|
||
}
|
||
return "未知世界"
|
||
}
|
||
|
||
func (s *RealmService) ruinSpawnRate(tier int32) float64 {
|
||
key := fmt.Sprintf("world.ruin.spawn_rate_l%d", tier)
|
||
defaults := map[int32]float64{
|
||
2: 0.01,
|
||
3: 0.065,
|
||
4: 0.10,
|
||
5: 0.15,
|
||
6: 0.215,
|
||
}
|
||
return s.getConfigFloat(key, defaults[tier])
|
||
}
|
||
|
||
// checkCarryLimit 校验跨层携带是否违反硬上限。
|
||
func (s *RealmService) checkCarryLimit(ctx context.Context, c *db.Character, targetTier int32) error {
|
||
balances, err := s.store.GetCurrencyBalances(ctx, c.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for currencyCode, amount := range balances {
|
||
if amount <= 0 {
|
||
continue
|
||
}
|
||
worldTier, err := s.store.GetCurrencyWorldTier(ctx, currencyCode)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
// 货币层级低于目标-1 时进入硬上限判断
|
||
if worldTier < targetTier-1 {
|
||
cap := s.carryCap(currencyCode, targetTier)
|
||
if amount > cap {
|
||
return fmt.Errorf("carry limit exceeded for %s (max %.4f)", currencyCode, cap)
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// applyCarryLimit 对超 cap 的低阶货币执行系统回收,并记录审计。
|
||
func (s *RealmService) applyCarryLimit(ctx context.Context, c *db.Character, targetTier int32) error {
|
||
balances, err := s.store.GetCurrencyBalances(ctx, c.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for currencyCode, amount := range balances {
|
||
if amount <= 0 {
|
||
continue
|
||
}
|
||
worldTier, err := s.store.GetCurrencyWorldTier(ctx, currencyCode)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if worldTier < targetTier-1 {
|
||
cap := s.carryCap(currencyCode, targetTier)
|
||
if amount > cap {
|
||
removed := amount - cap
|
||
if err := s.store.SetCurrencyBalance(ctx, c.ID, currencyCode, cap); err != nil {
|
||
return err
|
||
}
|
||
if err := s.store.AuditCurrencyFlow(ctx, c.ID, "character", c.ID, currencyCode, "sink", "world_break_carry_tax",
|
||
-removed, cap, c.WorldTier, ""); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *RealmService) carryCap(currencyCode string, targetTier int32) float64 {
|
||
// 目标 L2 时低阶货币保留较多;L3+ 仅允许保留少量低阶货币
|
||
switch targetTier {
|
||
case 2:
|
||
return 50000
|
||
case 3:
|
||
if currencyCode == "copper" || currencyCode == "silver" {
|
||
return 0 // 铜钱/银两不可带入灵石世界
|
||
}
|
||
return 100
|
||
case 4, 5, 6:
|
||
if currencyCode == "copper" || currencyCode == "silver" {
|
||
return 0
|
||
}
|
||
return 10
|
||
}
|
||
return math.MaxFloat64
|
||
}
|