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 }