diff --git a/biz/service/seed/interaction.go b/biz/service/seed/interaction.go new file mode 100644 index 0000000..a99a084 --- /dev/null +++ b/biz/service/seed/interaction.go @@ -0,0 +1,350 @@ +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) + } + if recs, err := dao.Element.Model().Ctx(ctx). + WhereIn("id", uniqueInt64(ids)).Where("status", consts.StatusEnabled).All(); err == nil { + 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) + 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) ([]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}) + } + } + } + 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 事务:原入口 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 +} diff --git a/biz/service/seed/interaction_test.go b/biz/service/seed/interaction_test.go new file mode 100644 index 0000000..96b1a8a --- /dev/null +++ b/biz/service/seed/interaction_test.go @@ -0,0 +1,119 @@ +package seed + +import ( + "encoding/json" + "testing" + + "github.com/gogf/gf/v2/container/gvar" + "github.com/gogf/gf/v2/database/gdb" + + "36wisdom/biz/consts" +) + +// gdb.Record = map[string]gdb.Value = map[string]*gvar.Var(gf v2.10.2),字面量一律 gvar.New +// 构造与 level 1 同构的小树:entry(1) → 决策(2) → best(3)/good(4)/fail(5) +func testTree() (nodes []gdb.Record, options []gdb.Record) { + nodes = []gdb.Record{ + {"id": gvar.New(1), "character_id": gvar.New(11), "result_type": gvar.New(0)}, + {"id": gvar.New(2), "character_id": gvar.New(12), "result_type": gvar.New(0)}, + {"id": gvar.New(3), "character_id": gvar.New(12), "result_type": gvar.New(3)}, + {"id": gvar.New(4), "character_id": gvar.New(12), "result_type": gvar.New(2)}, + {"id": gvar.New(5), "character_id": gvar.New(11), "result_type": gvar.New(1)}, + } + options = []gdb.Record{ + {"id": gvar.New(101), "node_id": gvar.New(1), "prop_id": gvar.New(21), "next_node_id": gvar.New(2), "sort_order": gvar.New(1)}, + {"id": gvar.New(102), "node_id": gvar.New(1), "prop_id": gvar.New(22), "next_node_id": gvar.New(5), "sort_order": gvar.New(2)}, + {"id": gvar.New(103), "node_id": gvar.New(2), "prop_id": gvar.New(21), "next_node_id": gvar.New(3), "sort_order": gvar.New(1)}, + {"id": gvar.New(104), "node_id": gvar.New(2), "prop_id": gvar.New(23), "next_node_id": gvar.New(4), "sort_order": gvar.New(2)}, + {"id": gvar.New(105), "node_id": gvar.New(2), "prop_id": gvar.New(24), "next_node_id": gvar.New(5), "sort_order": gvar.New(3)}, + } + return +} + +func elems() map[int64]string { + return map[int64]string{11: "小明", 12: "妈妈", 21: "画", 22: "扫帚", 23: "剪刀", 24: "长杆"} +} + +func TestPlanInteraction_BestPropAndFailFinal(t *testing.T) { + nodes, options := testTree() + p := planInteraction(2, 1, nodes, options, elems()) + if p == nil { + t.Fatal("应生成互动计划") + } + // 正确答案 = 能到达最佳终局(3)的第一个选项(103)的道具 21 + if p.answer != 21 { + t.Fatalf("正确答案应 21(选项103的道具),实际 %d", p.answer) + } + // 失败出口 = 首个失败终局 5 + if p.failFinalID != 5 { + t.Fatalf("失败终局应 5,实际 %d", p.failFinalID) + } + // 道具按选项顺序去重:21,22,23,24 + if len(p.items) != 4 || p.items[0].ID != 21 || p.items[3].ID != 24 { + t.Fatalf("道具顺序错误: %+v", p.items) + } +} + +func TestPlanInteraction_StepSortReversed(t *testing.T) { + nodes, options := testTree() + p := planInteraction(3, 1, nodes, options, elems()) + if len(p.answerOrder) != 4 { + t.Fatalf("排序应有 4 项,实际 %d", len(p.answerOrder)) + } + // 正确顺序 = 道具顺序逆序 + if p.answerOrder[0] != 24 || p.answerOrder[3] != 21 { + t.Fatalf("逆序错误: %v", p.answerOrder) + } +} + +func TestPlanInteraction_LinkMatchPairs(t *testing.T) { + nodes, options := testTree() + p := planInteraction(8, 1, nodes, options, elems()) + if p == nil { + t.Fatal("应生成连线计划") + } + // 人物:11(小明)、12(妈妈);小明(节点1)首个带道具选项→扫帚22;妈妈(节点2)→画21 + found := false + for _, pair := range p.pairs { + if pair[0] == 11 && pair[1] == 22 { + found = true + } + } + if !found { + t.Fatalf("缺少 小明-扫帚 配对: %v", p.pairs) + } +} + +func TestPlanInteraction_FallbackToPropPick(t *testing.T) { + // 只有 1 个道具 → step_sort 退化为 prop_pick + nodes, options := testTree() + options = options[:1] + p := planInteraction(3, 1, nodes, options, elems()) + if p == nil || p.kind != "prop_pick" { + t.Fatalf("应退化为 prop_pick,实际 %+v", p) + } +} + +func TestPlanInteraction_NoFailFinalSkipped(t *testing.T) { + nodes, options := testTree() + for i := range nodes { + if nodes[i]["result_type"].Int() == consts.ResultFail { + nodes[i]["result_type"] = gvar.New(2) + } + } + if p := planInteraction(2, 1, nodes, options, elems()); p != nil { + t.Fatal("无失败终局应返回 nil(跳过该关)") + } +} + +func TestInteractionConfig_JSON(t *testing.T) { + p := &interactionPlan{kind: "prop_pick", items: []configItem{{ID: 21, Name: "画"}}, answer: 21, prompt: "选一选"} + s := interactionConfigJSON(p) + var m map[string]any + if err := json.Unmarshal([]byte(s), &m); err != nil { + t.Fatalf("config 非法 JSON: %v", err) + } + if m["kind"] != "prop_pick" || m["answer"] != float64(21) { + t.Fatalf("config 字段错误: %v", m) + } +} diff --git a/biz/service/seed/seed.go b/biz/service/seed/seed.go index 683175a..47e5abf 100644 --- a/biz/service/seed/seed.go +++ b/biz/service/seed/seed.go @@ -130,7 +130,7 @@ func EnsureSeeded(ctx context.Context) { g.Log().Info(ctx, "seed: 种子数据导入完成") } annotateAll(ctx) - // ensureInteractions(ctx) // Task 4 打开 + ensureInteractions(ctx) } func doSeed(ctx context.Context) (err error) {