358 lines
11 KiB
Go
358 lines
11 KiB
Go
package seed
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/gogf/gf/v2/database/gdb"
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
|
|
"36wisdom/biz/consts"
|
|
"36wisdom/biz/dao"
|
|
"36wisdom/common"
|
|
)
|
|
|
|
// interactionKinds 触控互动类型轮换顺序(技术设计.md 4.7:2道具选择 3步骤排序 5拖拽放置 7找线索 8连线配对)
|
|
var interactionKinds = []int{2, 7, 5, 3, 8}
|
|
|
|
// interactionPrompts 各互动类型的节点提示语
|
|
var interactionPrompts = map[int]string{
|
|
2: "选一选:哪个道具能帮上忙?",
|
|
7: "找一找:线索在哪里?点一点",
|
|
5: "拖一拖:把道具放到框里",
|
|
3: "排一排:按顺序摆好道具",
|
|
8: "连一连:谁会用哪个道具?",
|
|
}
|
|
|
|
type configItem struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// interactionPlan 互动计划。字段包内小写,JSON 视图由 MarshalJSON 导出(前端渲染数据)。
|
|
type interactionPlan struct {
|
|
kind string // prop_pick / find_spot / drag_place / step_sort / link_match
|
|
items []configItem // 道具列表(选项 prop 按出现顺序去重)
|
|
answer int64 // 正确答案道具 id
|
|
answerOrder []int64 // step_sort 正确顺序(道具逆序)
|
|
persons []configItem // link_match 左列人物(有配对者)
|
|
pairs [][2]int64 // link_match 人物-道具配对 [人物id, 道具id]
|
|
entryID int64 // 成功出口目标:原入口节点
|
|
failFinalID int64 // 失败出口目标:本关首个失败终局
|
|
prompt string
|
|
}
|
|
|
|
// MarshalJSON 自定义序列化:unexported 字段不进 encoding/json,这里导出前端渲染所需的
|
|
// JSON 视图(kind/items/answer 必含,answer_order/persons/pairs 按互动类型出现)。
|
|
func (p *interactionPlan) MarshalJSON() ([]byte, error) {
|
|
type view struct {
|
|
Kind string `json:"kind"`
|
|
Items []configItem `json:"items"`
|
|
Answer int64 `json:"answer"`
|
|
AnswerOrder []int64 `json:"answer_order,omitempty"`
|
|
Persons []configItem `json:"persons,omitempty"`
|
|
Pairs [][2]int64 `json:"pairs,omitempty"`
|
|
}
|
|
return json.Marshal(view{
|
|
Kind: p.kind, Items: p.items, Answer: p.answer,
|
|
AnswerOrder: p.answerOrder, Persons: p.persons, Pairs: p.pairs,
|
|
})
|
|
}
|
|
|
|
// ensureInteractions 每关生成互动入口节点(幂等):入口节点 interaction_type>1 视为已有,跳过。
|
|
func ensureInteractions(ctx context.Context) {
|
|
levels, err := dao.Level.Model().Ctx(ctx).
|
|
Where("status", consts.StatusEnabled).Order("id ASC").All()
|
|
if err != nil {
|
|
g.Log().Warningf(ctx, "seed: 互动节点生成失败(查关卡): %v", err)
|
|
return
|
|
}
|
|
for i, lv := range levels {
|
|
if err := ensureLevelInteraction(ctx, lv, interactionKinds[i%len(interactionKinds)]); err != nil {
|
|
g.Log().Warningf(ctx, "seed: 互动节点生成失败 level=%d: %v", lv["id"].Int64(), err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func ensureLevelInteraction(ctx context.Context, lv gdb.Record, kind int) error {
|
|
levelId := lv["id"].Int64()
|
|
nodes, err := dao.SceneNode.Model().Ctx(ctx).
|
|
Where("level_id", levelId).Where("status", consts.StatusEnabled).
|
|
Order("sort_order ASC, id ASC").All()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var entry gdb.Record
|
|
for _, n := range nodes {
|
|
if n["is_entry"].Int() == 1 {
|
|
entry = n
|
|
break
|
|
}
|
|
}
|
|
if entry.IsEmpty() {
|
|
return fmt.Errorf("关卡 %d 无入口节点", levelId)
|
|
}
|
|
if entry["interaction_type"].Int() > 1 {
|
|
return nil // 已有互动入口,幂等跳过
|
|
}
|
|
|
|
options, err := dao.NodeOption.Model().Ctx(ctx).
|
|
WhereIn("node_id", nodeIdsOf(nodes)).Where("status", consts.StatusEnabled).
|
|
Order("sort_order ASC, id ASC").All()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
elementNames := map[int64]string{}
|
|
for _, n := range nodes {
|
|
if cid := n["character_id"].Int64(); cid > 0 {
|
|
elementNames[cid] = ""
|
|
}
|
|
}
|
|
for _, o := range options {
|
|
if pid := o["prop_id"].Int64(); pid > 0 {
|
|
elementNames[pid] = ""
|
|
}
|
|
}
|
|
if len(elementNames) > 0 {
|
|
ids := make([]int64, 0, len(elementNames))
|
|
for id := range elementNames {
|
|
ids = append(ids, id)
|
|
}
|
|
recs, err := dao.Element.Model().Ctx(ctx).
|
|
WhereIn("id", uniqueInt64(ids)).Where("status", consts.StatusEnabled).All()
|
|
if err != nil {
|
|
g.Log().Warningf(ctx, "seed: 元素名加载失败 level=%d: %v", levelId, err)
|
|
} else {
|
|
for _, r := range recs {
|
|
elementNames[r["id"].Int64()] = r["name"].String()
|
|
}
|
|
}
|
|
}
|
|
|
|
plan := planInteraction(kind, entry["id"].Int64(), nodes, options, elementNames)
|
|
if plan == nil {
|
|
return nil // 数据不足(无失败终局等),该关不插互动节点
|
|
}
|
|
return insertInteractionNode(ctx, levelId, kind, plan)
|
|
}
|
|
|
|
// planInteraction 纯函数:从关卡节点/选项派生互动计划;数据不足返回 nil。
|
|
func planInteraction(kind int, entryID int64, nodes, options []gdb.Record, elementNames map[int64]string) *interactionPlan {
|
|
// 失败终局:本关第一个 result_type=1 的节点;无则整关跳过
|
|
failFinalID := int64(0)
|
|
for _, n := range nodes {
|
|
if n["result_type"].Int() == consts.ResultFail {
|
|
failFinalID = n["id"].Int64()
|
|
break
|
|
}
|
|
}
|
|
if failFinalID == 0 {
|
|
return nil
|
|
}
|
|
|
|
// 道具:按选项出现顺序去重
|
|
items := []configItem{}
|
|
seenProp := map[int64]bool{}
|
|
for _, o := range options {
|
|
pid := o["prop_id"].Int64()
|
|
if pid == 0 || seenProp[pid] {
|
|
continue
|
|
}
|
|
seenProp[pid] = true
|
|
items = append(items, configItem{ID: pid, Name: elementNames[pid]})
|
|
}
|
|
|
|
// 正确答案:第一个其子树能到达最佳终局(result_type=3)的选项的道具;
|
|
// 找不到时退化为道具列表首个(单道具等退化场景仍有答案可判)
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
answer := bestPropOf(options, nodes)
|
|
if answer == 0 {
|
|
answer = items[0].ID
|
|
}
|
|
|
|
p := &interactionPlan{
|
|
kind: kindOf(kind, items),
|
|
items: items,
|
|
answer: answer,
|
|
entryID: entryID,
|
|
failFinalID: failFinalID,
|
|
prompt: interactionPrompts[kind],
|
|
}
|
|
switch p.kind {
|
|
case "step_sort":
|
|
for i := len(items) - 1; i >= 0; i-- {
|
|
p.answerOrder = append(p.answerOrder, items[i].ID)
|
|
}
|
|
case "link_match":
|
|
p.persons, p.pairs = pairsOf(nodes, options, elementNames)
|
|
if len(p.pairs) < 2 {
|
|
// 配对不足退化为道具选择
|
|
p.kind = "prop_pick"
|
|
p.prompt = interactionPrompts[2]
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// kindOf 类型降级:步骤排序需 ≥2 道具,连线配对需 ≥2 人物配对,否则退化为道具选择。
|
|
func kindOf(kind int, items []configItem) string {
|
|
switch kind {
|
|
case 3:
|
|
if len(items) >= 2 {
|
|
return "step_sort"
|
|
}
|
|
case 8:
|
|
return "link_match"
|
|
case 7:
|
|
return "find_spot"
|
|
case 5:
|
|
return "drag_place"
|
|
}
|
|
return "prop_pick"
|
|
}
|
|
|
|
// bestPropOf 第一个(选项顺序)其子树内存在最佳终局的选项的道具 id。
|
|
func bestPropOf(options []gdb.Record, nodes []gdb.Record) int64 {
|
|
nextOf := map[int64][]int64{} // node_id → 可达下一节点(通过其选项)
|
|
for _, o := range options {
|
|
nextOf[o["node_id"].Int64()] = append(nextOf[o["node_id"].Int64()], o["next_node_id"].Int64())
|
|
}
|
|
best := map[int64]bool{}
|
|
for _, n := range nodes {
|
|
if n["result_type"].Int() == consts.ResultBest {
|
|
best[n["id"].Int64()] = true
|
|
}
|
|
}
|
|
canReachBest := func(start int64) bool {
|
|
visited := map[int64]bool{}
|
|
queue := []int64{start}
|
|
for len(queue) > 0 {
|
|
cur := queue[0]
|
|
queue = queue[1:]
|
|
if visited[cur] {
|
|
continue
|
|
}
|
|
visited[cur] = true
|
|
if best[cur] {
|
|
return true
|
|
}
|
|
queue = append(queue, nextOf[cur]...)
|
|
}
|
|
return false
|
|
}
|
|
for _, o := range options {
|
|
if canReachBest(o["next_node_id"].Int64()) {
|
|
return o["prop_id"].Int64()
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// pairsOf 人物-道具配对:每个去重人物取其最后一个带道具选项的道具,返回人物列表与配对。
|
|
func pairsOf(nodes, options []gdb.Record, elementNames map[int64]string) ([]configItem, [][2]int64) {
|
|
propOf := map[int64]int64{} // node_id → 最后一个带道具选项的道具
|
|
for _, o := range options {
|
|
pid := o["prop_id"].Int64()
|
|
if pid == 0 {
|
|
continue
|
|
}
|
|
propOf[o["node_id"].Int64()] = pid // 后出现者覆盖:取最后一个
|
|
}
|
|
persons := []configItem{}
|
|
seen := map[int64]bool{}
|
|
pairs := [][2]int64{}
|
|
for _, n := range nodes {
|
|
cid := n["character_id"].Int64()
|
|
if cid == 0 {
|
|
continue
|
|
}
|
|
if pid, ok := propOf[n["id"].Int64()]; ok {
|
|
pairs = append(pairs, [2]int64{cid, pid})
|
|
if !seen[cid] {
|
|
seen[cid] = true
|
|
persons = append(persons, configItem{ID: cid, Name: elementNames[cid]})
|
|
}
|
|
}
|
|
}
|
|
return persons, pairs
|
|
}
|
|
|
|
// interactionConfigJSON 互动配置(前端渲染数据)。
|
|
func interactionConfigJSON(p *interactionPlan) string {
|
|
b, _ := json.Marshal(p)
|
|
return string(b)
|
|
}
|
|
|
|
func nodeIdsOf(nodes []gdb.Record) []int64 {
|
|
ids := make([]int64, 0, len(nodes))
|
|
for _, n := range nodes {
|
|
ids = append(ids, n["id"].Int64())
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// insertInteractionNode 写入 scene_node / node_option,两表读查询走 contentCache。
|
|
// 本函数仅在 EnsureSeeded 启动期调用(先于 HTTP 服务监听),内存 gcache 无并发读者,
|
|
// 无脏读可能,故按 annotateAll 先例不清缓存;若将来改为运行期调用须先清对应缓存。
|
|
//
|
|
// 事务:原入口 is_entry=0 → 插互动节点(is_entry=1) → 插成功/失败出口选项。
|
|
func insertInteractionNode(ctx context.Context, levelId int64, kind int, plan *interactionPlan) error {
|
|
promptPinyin := common.AnnotatePinyin(plan.prompt)
|
|
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
|
if _, err := tx.Model(consts.TableSceneNode).Ctx(ctx).
|
|
Data(g.Map{"is_entry": 0}).WherePri(plan.entryID).Update(); err != nil {
|
|
return err
|
|
}
|
|
res, err := tx.Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{
|
|
"level_id": levelId, "title": "动动小脑筋",
|
|
"content": plan.prompt, "content_pinyin": promptPinyin,
|
|
"node_type": consts.NodeDecision, "interaction_type": kind,
|
|
"config": interactionConfigJSON(plan), "result_type": consts.ResultNone,
|
|
"is_entry": 1, "sort_order": 1, "status": consts.StatusEnabled,
|
|
}).Insert()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nodeId, err := res.LastInsertId()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Model(consts.TableNodeOption).Ctx(ctx).Data(g.Map{
|
|
"node_id": nodeId, "text": "成功了",
|
|
"text_pinyin": common.AnnotatePinyin("成功了"), "next_node_id": plan.entryID,
|
|
"feedback": "真棒!我们看看接下来会发生什么~",
|
|
"feedback_pinyin": common.AnnotatePinyin("真棒!我们看看接下来会发生什么~"),
|
|
"sort_order": 1, "status": consts.StatusEnabled,
|
|
}).Insert(); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Model(consts.TableNodeOption).Ctx(ctx).Data(g.Map{
|
|
"node_id": nodeId, "text": "没成功",
|
|
"text_pinyin": common.AnnotatePinyin("没成功"), "next_node_id": plan.failFinalID,
|
|
"feedback": "没关系,再仔细观察一下~",
|
|
"feedback_pinyin": common.AnnotatePinyin("没关系,再仔细观察一下~"),
|
|
"sort_order": 2, "status": consts.StatusEnabled,
|
|
}).Insert(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// uniqueInt64 整数去重(保持首次出现顺序)。biz/service 包的同名函数不可跨包访问。
|
|
func uniqueInt64(in []int64) []int64 {
|
|
seen := make(map[int64]struct{}, len(in))
|
|
out := make([]int64, 0, len(in))
|
|
for _, v := range in {
|
|
if _, ok := seen[v]; ok {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|