package main import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" "unicode/utf8" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/glog" "36wisdom/biz/consts" "36wisdom/common" ) // scriptLine 台词流单句(导入时补 pinyin) type scriptLine struct { Speaker string `json:"speaker"` Text string `json:"text"` Emotion string `json:"emotion"` } // scriptDraft 草稿文件结构(人工精修对象;pinyin 不入草稿,导入时统一生成) type scriptDraft struct { LevelID int64 `json:"level_id"` NodeID int64 `json:"node_id"` Character string `json:"character"` Decision bool `json:"decision"` Content string `json:"content"` Options []string `json:"options,omitempty"` Script []scriptLine `json:"script"` } func scriptDir(levelID int64) string { return filepath.Join(assetDir, "scripts", fmt.Sprintf("%d", levelID)) } func scriptPath(levelID, nodeID int64) string { return filepath.Join(scriptDir(levelID), fmt.Sprintf("%d.json", nodeID)) } // pendingScriptNodes 待生成/待导入剧本的节点(script 为空) func pendingScriptNodes(fl genFlags) []gdb.Record { m := g.DB().Model(consts.TableSceneNode).Where("script IS NULL OR script = ''") if fl.strategy > 0 { levelIDs, _ := strategyScope(fl) m = m.WhereIn("level_id", levelIDs) } rows, err := m.All(ctx) if err != nil { glog.Fatal(ctx, err) } return rows } // ---------- 生成 ---------- func genScripts(fl genFlags) { rows := pendingScriptNodes(fl) if len(rows) == 0 { fmt.Println("[scripts] 无待生成节点") return } charNames := scriptCharNames(rows) optsByNode := scriptOptions(rows) sem := make(chan struct{}, 2) var wg sync.WaitGroup for _, r := range rows { if !fl.force && fileExists(scriptPath(r["level_id"].Int64(), r["id"].Int64())) { continue // 幂等:草稿已存在跳过(--force 覆盖;防重跑覆盖人工精修草稿) } wg.Add(1) sem <- struct{}{} go func(r gdb.Record) { defer wg.Done() defer func() { <-sem }() if err := genNodeScript(r, charNames, optsByNode); err != nil { glog.Errorf(ctx, "[scripts] node %d 失败: %v", r["id"].Int64(), err) } }(r) } wg.Wait() } func scriptCharNames(rows []gdb.Record) map[int64]string { charIDs := map[int64]bool{} for _, r := range rows { if cid := r["character_id"].Int64(); cid > 0 { charIDs[cid] = true } } names := map[int64]string{} if len(charIDs) == 0 { return names } ids := make([]int64, 0, len(charIDs)) for id := range charIDs { ids = append(ids, id) } recs, err := g.DB().Model(consts.TableElement).WhereIn("id", ids).All(ctx) if err != nil { glog.Fatal(ctx, err) } for _, r := range recs { names[r["id"].Int64()] = r["name"].String() } return names } func scriptOptions(rows []gdb.Record) map[int64][]string { var nodeIDs []int64 for _, r := range rows { nodeIDs = append(nodeIDs, r["id"].Int64()) } byNode := map[int64][]string{} if len(nodeIDs) == 0 { return byNode } opts, err := g.DB().Model(consts.TableNodeOption).WhereIn("node_id", nodeIDs).All(ctx) if err != nil { glog.Fatal(ctx, err) } for _, o := range opts { nid := o["node_id"].Int64() byNode[nid] = append(byNode[nid], o["text"].String()) } return byNode } func genNodeScript(node gdb.Record, charNames map[int64]string, optsByNode map[int64][]string) error { nid := node["id"].Int64() charName := charNames[node["character_id"].Int64()] if charName == "" { charName = "小军师" } decision := node["result_type"].Int() == 0 var tail string if decision { opts := optsByNode[nid] lines := make([]string, 0, len(opts)) for i, o := range opts { lines = append(lines, fmt.Sprintf("%d. %s", i+1, o)) } tail = "选项:\n" + strings.Join(lines, "\n") } else { tail = "这是结局节点:由角色说出结局与鼓励,不做提问。" } user := fmt.Sprintf(scriptUser, node["content"].String(), charName, tail) out, err := genClient.chat(ctx, scriptSystem, user) if err != nil { return err } var resp struct { Script []scriptLine `json:"script"` } if err := json.Unmarshal([]byte(out), &resp); err != nil { return fmt.Errorf("剧本 JSON 解析失败: %v(输出: %s)", err, truncate(out, 200)) } if err := validateScript(resp.Script, decision, charName); err != nil { return fmt.Errorf("剧本校验失败: %v(输出: %s)", err, truncate(out, 300)) } draft := scriptDraft{ LevelID: node["level_id"].Int64(), NodeID: nid, Character: charName, Decision: decision, Content: node["content"].String(), Script: resp.Script, } if decision { draft.Options = optsByNode[nid] } b, _ := json.MarshalIndent(draft, "", " ") if err := os.MkdirAll(scriptDir(node["level_id"].Int64()), 0o755); err != nil { return err } if err := os.WriteFile(scriptPath(node["level_id"].Int64(), nid), b, 0o644); err != nil { return err } fmt.Printf("[scripts] ok node %d(%d 句)\n", nid, len(resp.Script)) return nil } // validateScript 校验台词流结构(生成与导入共用) func validateScript(lines []scriptLine, decision bool, charName string) error { if len(lines) < 3 || len(lines) > 6 { return fmt.Errorf("台词应为 3-6 句,实际 %d 句", len(lines)) } hasNar := false last := lines[len(lines)-1] for _, l := range lines { if l.Speaker == "旁白" { hasNar = true } else if l.Speaker != charName { return fmt.Errorf("speaker %q 非法(应为 旁白 或 %s)", l.Speaker, charName) } if n := utf8.RuneCountInString(l.Text); n < 8 || n > 30 { return fmt.Errorf("台词应 8-30 字,实际 %d 字:%s", n, l.Text) } if l.Emotion != "" && !validEmotion(l.Emotion) { return fmt.Errorf("emotion %q 非法", l.Emotion) } } if !hasNar { return fmt.Errorf("至少 1 句旁白") } if decision { if last.Speaker != charName { return fmt.Errorf("决策节点末句必须是 %s 提问", charName) } if !strings.HasSuffix(last.Text, "?") && !strings.HasSuffix(last.Text, "?") { return fmt.Errorf("决策节点末句必须以问号结尾:%s", last.Text) } } else if last.Speaker == "旁白" { return fmt.Errorf("终局节点末句应为角色总结") } return nil } func validEmotion(e string) bool { switch e { case "normal", "happy", "sad", "think", "surprise": return true } return false } // ---------- 导入 ---------- func importScripts(fl genFlags) { rows := pendingScriptNodes(fl) imported := 0 for _, r := range rows { p := scriptPath(r["level_id"].Int64(), r["id"].Int64()) b, err := os.ReadFile(p) if err != nil { continue // 无草稿跳过 } var d scriptDraft if err := json.Unmarshal(b, &d); err != nil { glog.Errorf(ctx, "[scripts] 草稿解析失败 %s: %v", p, err) continue } if d.NodeID != r["id"].Int64() { glog.Errorf(ctx, "[scripts] 草稿 node 不匹配 %s", p) continue } if err := validateScript(d.Script, d.Decision, d.Character); err != nil { glog.Errorf(ctx, "[scripts] 草稿校验失败 %s: %v", p, err) continue } lines := make([]map[string]string, 0, len(d.Script)) for _, l := range d.Script { lines = append(lines, map[string]string{ "speaker": l.Speaker, "text": l.Text, "pinyin": common.AnnotatePinyin(l.Text), "emotion": l.Emotion, }) } b2, _ := json.Marshal(lines) if _, err := g.DB().Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{"script": string(b2)}).Where("id", d.NodeID).Update(); err != nil { glog.Errorf(ctx, "[scripts] 导入失败 node %d: %v", d.NodeID, err) continue } fmt.Printf("[scripts] 导入 node %d\n", d.NodeID) imported++ } fmt.Printf("[scripts] 导入完成:%d 个节点\n", imported) }