Files
36Wisdom/cmd/genasset/scripts.go
T

269 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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).Data(g.Map{"script": string(b2)}).Where("id", d.NodeID).Update(ctx); 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)
}