choose 校验节点/选项归属与解锁链,终局在 common.WithLock 内按 技术设计 4.3/4.4 结算:首次到达才结算、连续失败限扣、余额不为负、 完美集齐全终局 +20 并解锁计策卡与下一计;路径流水尽力而为。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
422 lines
13 KiB
Go
422 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/gogf/gf/v2/database/gdb"
|
|
"github.com/gogf/gf/v2/errors/gerror"
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/gogf/gf/v2/os/gtime"
|
|
|
|
"36wisdom/biz/consts"
|
|
"36wisdom/biz/dao"
|
|
"36wisdom/common"
|
|
)
|
|
|
|
type levelPlay struct{}
|
|
|
|
var LevelPlay = &levelPlay{}
|
|
|
|
// SettleState 结算输入:该关历史状态(由进度 + 路径流水推导,锁内读取)。
|
|
type SettleState struct {
|
|
LevelCleared bool // 该关已通关(到过最佳终局)
|
|
FailStreak int // 连续失败终局次数(非失败终局打断连续)
|
|
ReachedFinals map[int64]bool // 已到达终局节点 id 集合
|
|
TotalFinals int // 该关终局节点总数
|
|
PerfectAwarded bool // 完美奖励是否已发放(进度表为准)
|
|
BalanceAfter int // 结算前余额
|
|
}
|
|
|
|
// FinalSettle 结算结果(service 层领域值,controller 映射为 dto)。
|
|
type FinalSettle struct {
|
|
ResultType int
|
|
Stars int
|
|
ScoreDelta int
|
|
Cleared bool
|
|
Perfect bool
|
|
UnlockNext bool
|
|
CollectionUnlocked bool
|
|
NewLevel int
|
|
BalanceAfter int
|
|
}
|
|
|
|
// SettleFinal 纯函数判分(技术设计 4.3/4.4,可单测):
|
|
// - 首次到达某终局才结算,重复到达 delta=0
|
|
// - 未通关:最佳 +30 / 良好 +10 / 失败 -10(连续失败 ≥2 次后不再扣;余额扣至 0 不为负)
|
|
// - 已通关后补分支:只记完成度不结算积分
|
|
// - 终局数集齐 → 完美(+20);最佳终局 → 通关
|
|
func SettleFinal(s SettleState, finalNodeId int64, resultType int) FinalSettle {
|
|
f := FinalSettle{ResultType: resultType, BalanceAfter: s.BalanceAfter}
|
|
switch resultType {
|
|
case consts.ResultBest:
|
|
f.Stars = 3
|
|
case consts.ResultGood:
|
|
f.Stars = 2
|
|
default:
|
|
f.Stars = 0
|
|
}
|
|
|
|
newNode := !s.ReachedFinals[finalNodeId]
|
|
if newNode && !s.LevelCleared {
|
|
switch resultType {
|
|
case consts.ResultBest:
|
|
f.ScoreDelta = consts.PointsBest
|
|
case consts.ResultGood:
|
|
f.ScoreDelta = consts.PointsGood
|
|
default:
|
|
if s.FailStreak < consts.FailNoDeductAfter {
|
|
f.ScoreDelta = consts.PointsFail
|
|
}
|
|
}
|
|
}
|
|
if f.ScoreDelta < 0 && -f.ScoreDelta > s.BalanceAfter {
|
|
f.ScoreDelta = -s.BalanceAfter
|
|
}
|
|
|
|
f.Cleared = s.LevelCleared || resultType == consts.ResultBest
|
|
|
|
reached := len(s.ReachedFinals)
|
|
if newNode {
|
|
reached++
|
|
}
|
|
// 完美以进度表标记为准发放一次(日志为尽力而为的记录,可能漂移)
|
|
if reached == s.TotalFinals && s.TotalFinals > 0 && !s.PerfectAwarded {
|
|
f.Perfect = true
|
|
f.ScoreDelta += consts.PointsPerfect
|
|
}
|
|
|
|
f.BalanceAfter = s.BalanceAfter + f.ScoreDelta
|
|
return f
|
|
}
|
|
|
|
// Choose 分支闯关:校验 node/option 归属与解锁 → 决策节点返回下一节点;
|
|
// 终局节点在锁内结算(事务写进度/积分/计策卡)。路径流水尽力而为,失败不阻断。
|
|
func (s *levelPlay) Choose(ctx context.Context, parentUid, childId, levelId, nodeId, optionId int64) (*Node, *FinalSettle, error) {
|
|
child, err := getChildOf(ctx, parentUid, childId)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
levelRec, err := dao.Level.Model().Ctx(ctx).Cache(contentCache(ctx)).WherePri(levelId).One()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if levelRec.IsEmpty() || levelRec["status"].Int() != consts.StatusEnabled {
|
|
return nil, nil, gerror.New("关卡不存在")
|
|
}
|
|
if levelRec["age_group"].String() != child["age_group"].String() {
|
|
return nil, nil, gerror.New("关卡不属于当前年龄段")
|
|
}
|
|
|
|
nodeRec, err := dao.SceneNode.Model().Ctx(ctx).Cache(contentCache(ctx)).
|
|
Where("id", nodeId).Where("level_id", levelId).Where("status", consts.StatusEnabled).One()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if nodeRec.IsEmpty() {
|
|
return nil, nil, gerror.New("节点不存在")
|
|
}
|
|
|
|
optionRec, err := dao.NodeOption.Model().Ctx(ctx).Cache(contentCache(ctx)).
|
|
Where("id", optionId).Where("node_id", nodeId).Where("status", consts.StatusEnabled).One()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if optionRec.IsEmpty() {
|
|
return nil, nil, gerror.New("选项不存在")
|
|
}
|
|
|
|
nextRec, err := dao.SceneNode.Model().Ctx(ctx).Cache(contentCache(ctx)).
|
|
Where("id", optionRec["next_node_id"].Int64()).Where("level_id", levelId).Where("status", consts.StatusEnabled).One()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if nextRec.IsEmpty() {
|
|
return nil, nil, gerror.New("目标节点不存在")
|
|
}
|
|
|
|
progressRec, err := dao.UserProgress.Model().Ctx(ctx).
|
|
Where("child_id", childId).Where("level_id", levelId).One()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !s.levelUnlocked(ctx, childId, child, levelRec, progressRec) {
|
|
return nil, nil, gerror.New("关卡未解锁")
|
|
}
|
|
|
|
if nextRec["result_type"].Int() == consts.ResultNone {
|
|
s.logRoute(ctx, childId, levelId, nodeId, optionId, consts.ResultNone)
|
|
node, err := nodeOf(ctx, levelRec, nextRec)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return node, nil, nil
|
|
}
|
|
|
|
// 终局:路径流水在锁内结算后记录,避免状态读到本次到达导致重复结算误判
|
|
settle, err := common.WithLock(ctx, fmt.Sprintf("child:%d:level:%d", childId, levelId), 10*time.Second, 3, 200*time.Millisecond, func() (*FinalSettle, error) {
|
|
return s.settle(ctx, child, levelRec, nextRec, nodeId, optionId)
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return nil, settle, nil
|
|
}
|
|
|
|
// levelUnlocked 关卡解锁判定:本关已通关可重玩,否则按解锁链校验计策解锁。
|
|
func (s *levelPlay) levelUnlocked(ctx context.Context, childId int64, child gdb.Record, levelRec, progressRec gdb.Record) bool {
|
|
if progressRec["stars"].Int() >= 3 {
|
|
return true
|
|
}
|
|
strategyRec, err := dao.Strategy.Model().Ctx(ctx).Cache(contentCache(ctx)).
|
|
WherePri(levelRec["strategy_id"].Int64()).One()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
unlocked, _ := Strategy.unlockState(ctx, childId, child["age_group"].String(), strategyRec)
|
|
return unlocked
|
|
}
|
|
|
|
// logRoute 路径流水(尽力而为,写失败不阻断主流程)。
|
|
func (s *levelPlay) logRoute(ctx context.Context, childId, levelId, nodeId, optionId int64, resultType int) {
|
|
_, _ = dao.UserRouteLog.InsertAndReturnId(ctx, g.Map{
|
|
"child_id": childId,
|
|
"level_id": levelId,
|
|
"node_id": nodeId,
|
|
"option_id": optionId,
|
|
"result_type": resultType,
|
|
})
|
|
}
|
|
|
|
// nodeOf 组装下一决策节点(选项 + 元素)。
|
|
func nodeOf(ctx context.Context, levelRec, nodeRec gdb.Record) (*Node, error) {
|
|
optionRecs, err := dao.NodeOption.Model().Ctx(ctx).Cache(contentCache(ctx)).
|
|
Where("node_id", nodeRec["id"].Int64()).Where("status", consts.StatusEnabled).
|
|
Order("sort_order ASC").All()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
elements, err := elementsOf(ctx, levelRec, []gdb.Record{nodeRec}, optionRecs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buildNode(nodeRec, optionRecs, elements), nil
|
|
}
|
|
|
|
// settle 终局结算(调用方持锁):锁内重读状态 → 纯函数判分 → 事务写库 → 记录本次终局路径 → 派生结果。
|
|
func (s *levelPlay) settle(ctx context.Context, child gdb.Record, levelRec, finalRec gdb.Record, nodeId, optionId int64) (*FinalSettle, error) {
|
|
childId := child["id"].Int64()
|
|
levelId := levelRec["id"].Int64()
|
|
|
|
state, err := s.settleState(ctx, childId, levelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f := SettleFinal(state, finalRec["id"].Int64(), finalRec["result_type"].Int())
|
|
|
|
if f.ScoreDelta != 0 || f.Cleared || f.Perfect {
|
|
if err = s.commitSettle(ctx, childId, levelRec, &f); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
s.logRoute(ctx, childId, levelId, nodeId, optionId, finalRec["result_type"].Int())
|
|
s.deriveExtras(ctx, child, levelRec, &f)
|
|
return &f, nil
|
|
}
|
|
|
|
// settleState 锁内重读该关历史状态:终局数、已到达终局、连续失败、余额。
|
|
func (s *levelPlay) settleState(ctx context.Context, childId, levelId int64) (SettleState, error) {
|
|
state := SettleState{ReachedFinals: map[int64]bool{}}
|
|
|
|
progressRec, err := dao.UserProgress.Model().Ctx(ctx).
|
|
Where("child_id", childId).Where("level_id", levelId).One()
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
state.LevelCleared = progressRec["stars"].Int() >= 3
|
|
state.PerfectAwarded = progressRec["perfect"].Int() == 1
|
|
|
|
childRec, err := dao.Child.Model().Ctx(ctx).WherePri(childId).One()
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
state.BalanceAfter = childRec["points"].Int()
|
|
|
|
nodeRecs, err := dao.SceneNode.Model().Ctx(ctx).
|
|
Where("level_id", levelId).Where("status", consts.StatusEnabled).All()
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
for _, n := range nodeRecs {
|
|
if n["result_type"].Int() > 0 {
|
|
state.TotalFinals++
|
|
}
|
|
}
|
|
|
|
logs, err := dao.UserRouteLog.Model().Ctx(ctx).
|
|
Where("child_id", childId).Where("level_id", levelId).
|
|
WhereGT("result_type", 0).Order("id ASC").All()
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
// 日志记录的是选择节点(node_id)+ 选项;终局节点经选项的 next_node_id 解析
|
|
optionIds := make([]int64, 0, len(logs))
|
|
for _, l := range logs {
|
|
optionIds = append(optionIds, l["option_id"].Int64())
|
|
}
|
|
nextOf := map[int64]int64{}
|
|
if len(optionIds) > 0 {
|
|
opts, err := dao.NodeOption.Model().Ctx(ctx).WhereIn("id", optionIds).All()
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
for _, o := range opts {
|
|
nextOf[o["id"].Int64()] = o["next_node_id"].Int64()
|
|
}
|
|
}
|
|
for _, l := range logs {
|
|
if nid, ok := nextOf[l["option_id"].Int64()]; ok {
|
|
state.ReachedFinals[nid] = true
|
|
}
|
|
}
|
|
for i := len(logs) - 1; i >= 0; i-- {
|
|
if logs[i]["result_type"].Int() == consts.ResultFail {
|
|
state.FailStreak++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
// commitSettle 结算事务:进度(最高星 + 完美标记 + 内容版本)、积分流水与余额、计策卡。
|
|
func (s *levelPlay) commitSettle(ctx context.Context, childId int64, levelRec gdb.Record, f *FinalSettle) error {
|
|
levelId := levelRec["id"].Int64()
|
|
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
|
progressRec, err := tx.Model(consts.TableUserProgress).Ctx(ctx).
|
|
Where("child_id", childId).Where("level_id", levelId).One()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stars := f.Stars
|
|
perfect := 0
|
|
if f.Perfect {
|
|
perfect = 1
|
|
}
|
|
if !progressRec.IsEmpty() {
|
|
if stars < progressRec["stars"].Int() {
|
|
stars = progressRec["stars"].Int()
|
|
}
|
|
perfect = progressRec["perfect"].Int() | perfect
|
|
}
|
|
progressData := g.Map{
|
|
"stars": stars,
|
|
"perfect": perfect,
|
|
"content_version": levelRec["content_version"].Int(),
|
|
"completed_at": gtime.Now(),
|
|
}
|
|
if progressRec.IsEmpty() {
|
|
progressData["child_id"] = childId
|
|
progressData["level_id"] = levelId
|
|
_, err = tx.Model(consts.TableUserProgress).Ctx(ctx).Data(progressData).Insert()
|
|
} else {
|
|
_, err = tx.Model(consts.TableUserProgress).Ctx(ctx).Data(progressData).
|
|
Where("child_id", childId).Where("level_id", levelId).Update()
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if f.ScoreDelta != 0 {
|
|
childRec, err := tx.Model(consts.TableChild).Ctx(ctx).WherePri(childId).One()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
balance := childRec["points"].Int() + f.ScoreDelta
|
|
if _, err = tx.Model(consts.TableChild).Ctx(ctx).Data(g.Map{"points": balance}).WherePri(childId).Update(); err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.Model(consts.TablePointLog).Ctx(ctx).Data(g.Map{
|
|
"user_id": childId,
|
|
"change": f.ScoreDelta,
|
|
"reason_type": consts.ReasonLevel,
|
|
"ref_id": levelId,
|
|
"balance_after": balance,
|
|
}).Insert(); err != nil {
|
|
return err
|
|
}
|
|
f.BalanceAfter = balance
|
|
}
|
|
|
|
return s.collectIfAllPerfect(ctx, tx, childId, levelRec, f)
|
|
})
|
|
}
|
|
|
|
// collectIfAllPerfect 本计全部关卡完美 → 写计策卡(防重复:已存在则跳过)。
|
|
func (s *levelPlay) collectIfAllPerfect(ctx context.Context, tx gdb.TX, childId int64, levelRec gdb.Record, f *FinalSettle) error {
|
|
if !f.Perfect {
|
|
return nil
|
|
}
|
|
strategyId := levelRec["strategy_id"].Int64()
|
|
levels, err := tx.Model(consts.TableLevel).Ctx(ctx).
|
|
Where("strategy_id", strategyId).Where("status", consts.StatusEnabled).All()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, lv := range levels {
|
|
rec, err := tx.Model(consts.TableUserProgress).Ctx(ctx).
|
|
Where("child_id", childId).Where("level_id", lv["id"].Int64()).One()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rec.IsEmpty() || rec["perfect"].Int() != 1 {
|
|
return nil
|
|
}
|
|
}
|
|
exists, err := tx.Model(consts.TableUserCollection).Ctx(ctx).
|
|
Where("user_id", childId).Where("strategy_id", strategyId).Count()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exists > 0 {
|
|
return nil
|
|
}
|
|
if _, err = tx.Model(consts.TableUserCollection).Ctx(ctx).Data(g.Map{
|
|
"user_id": childId,
|
|
"strategy_id": strategyId,
|
|
}).Insert(); err != nil {
|
|
return err
|
|
}
|
|
f.CollectionUnlocked = true
|
|
return nil
|
|
}
|
|
|
|
// deriveExtras 结算后派生:计策卡解锁提示、下一计解锁、成长等级。
|
|
func (s *levelPlay) deriveExtras(ctx context.Context, child gdb.Record, levelRec gdb.Record, f *FinalSettle) {
|
|
childId := child["id"].Int64()
|
|
|
|
if f.CollectionUnlocked {
|
|
if next, err := dao.Strategy.Model().Ctx(ctx).Cache(contentCache(ctx)).
|
|
Where("unlock_before", levelRec["strategy_id"].Int64()).One(); err == nil && !next.IsEmpty() {
|
|
f.UnlockNext = true
|
|
}
|
|
}
|
|
|
|
before, err := dao.UserProgress.Model().Ctx(ctx).
|
|
Where("child_id", childId).Where("perfect", 1).Count()
|
|
if err != nil {
|
|
return
|
|
}
|
|
after := before
|
|
if f.Perfect {
|
|
after++
|
|
}
|
|
oldLevel, _ := LevelOf(before)
|
|
newLevel, _ := LevelOf(after)
|
|
if oldLevel != newLevel {
|
|
f.NewLevel = newLevel
|
|
}
|
|
}
|