Files
36Wisdom/biz/service/level_play.go
T
adminandClaude Opus 4.7 414c7de007 feat: 分支闯关判分与终局结算(含单测)
choose 校验节点/选项归属与解锁链,终局在 common.WithLock 内按
技术设计 4.3/4.4 结算:首次到达才结算、连续失败限扣、余额不为负、
完美集齐全终局 +20 并解锁计策卡与下一计;路径流水尽力而为。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-13 12:47:01 +08:00

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
}
}