// 剧本试走:从首节点走,点选项/掷随机/手选战斗胜负,模拟银两/道具/友好度账面。
// out_ref 用返回栈进出子序列;skip 选项命中目标结局时按 skipReward 覆盖结算(对齐运行时)。
(function () {
let IR, DICT, flow, ledger, state, retStack, pendingSkip;
function nameOf(s) { const r = (IR.roles || []).find(x => x.slot === s); return s === "P1" ? "玩家" : (r ? r.name : s); }
function grantForm(kind) { return ((DICT.grants || {})[kind] || {}).form; }
function seqMap() { const m = {}; (IR.sequences || []).forEach(s => m[s.id] = s); return m; }
function mainNode(id) { return (IR.nodes || []).find(n => n.id === id); }
function ending(id) { return (IR.endings || []).find(e => e.id === id); }
function nodeAt(loc) {
if (!loc) return null;
if (loc.seq) { const s = seqMap()[loc.seq]; return s && s.nodes.find(n => n.id === loc.id); }
return mainNode(loc.id) || ending(loc.id);
}
function seqHas(seqId, id) { const s = seqMap()[seqId]; return s && s.nodes.some(n => n.id === id); }
function advance(loc) {
const n = nodeAt(loc); const nx = n && n.next;
if (nx) {
if (loc.seq && seqHas(loc.seq, nx)) return { seq: loc.seq, id: nx };
return { seq: null, id: nx };
}
if (loc.seq) { const ex = retStack.pop(); return ex ? { seq: null, id: ex } : null; }
return null;
}
function enterSeq(seqId, exit) {
retStack.push(exit || "");
const s = seqMap()[seqId];
if (!s || !(s.nodes || []).length) return null;
return { seq: seqId, id: s.nodes[0].id };
}
function applyGrants(grants, srcLabel) {
(grants || []).forEach(g => {
const f = grantForm(g.kind);
if (f === "money") state.银两 += (g.value || 0);
else if (f === "item") state.道具[g.item] = (state.道具[g.item] || 0) + (g.value || 0);
else if (f === "friend") state.友好度[g.target] = (state.友好度[g.target] || 0) + (g.value || 0);
else if (f === "join") state.入门.push(nameOf(g.target));
});
if (grants && grants.length) renderLedger();
}
function condMet(c) {
if (!c) return true;
if (c.kind === "银两") {
if (c.op === ">=") return state.银两 >= c.value;
if (c.op === ">") return state.银两 > c.value;
if (c.op === "<=") return state.银两 <= c.value;
if (c.op === "<") return state.银两 < c.value;
if (c.op === "==") return state.银两 === c.value;
}
return true; // 未知条件不拦
}
// ---- 渲染 ----
function add(cls, html) { const d = document.createElement("div"); d.className = "pt-step " + cls; d.innerHTML = html; flow.appendChild(d); flow.scrollTop = flow.scrollHeight; return d; }
function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">"); }
function renderLedger() {
let h = "
账面
";
h += '银两:' + state.银两 + "
";
const items = Object.entries(state.道具).filter(([, v]) => v);
h += '道具:' + (items.length ? items.map(([k, v]) => k + "×" + v).join(",") : "—") + "
";
const fr = Object.entries(state.友好度).filter(([, v]) => v);
h += '友好度:' + (fr.length ? fr.map(([k, v]) => nameOf(k) + "+" + v).join(",") : "—") + "
";
if (state.入门.length) h += '入门:' + state.入门.join(",") + "
";
ledger.innerHTML = h;
}
function walk(loc) {
let guard = 0;
while (loc && guard++ < 500) {
const n = nodeAt(loc);
if (!n) { add("sys", "⚠ 断链:目标节点不存在,流程中断"); return; }
const isEnd = !loc.seq && ending(loc.id);
if (isEnd) {
const e = n;
let grants = e.grants;
if (pendingSkip && pendingSkip.node === loc.id) { grants = pendingSkip.grants; add("sys", "(押注命中:结算改用 skip 彩头)"); }
applyGrants(grants, "结局");
const res = { success: "成功", fail: "失败", end: "中性" }[e.result || "success"];
add("end", "★ 结局:" + esc(e.summary || loc.id) + "(" + res + ")");
add("sys", "—— 试走结束 ——");
return;
}
const k = n.kind;
if (k === "narration") { add("spk", "" + esc(nameOf(n.speaker || "P1")) + ":" + esc(n.text)); loc = advance(loc); }
else if (k === "dialogue") { add("spk", "" + esc(nameOf(n.speaker)) + ":" + esc(n.text)); loc = advance(loc); }
else if (k === "move") { add("sys", "〔走位〕" + nameOf(n.actor) + " → " + (n.to || "")); loc = advance(loc); }
else if (k === "anim") { add("sys", "〔动画〕" + nameOf(n.actor) + " " + (n.ani || "")); loc = advance(loc); }
else if (k === "reward") { applyGrants(n.grants, "结算"); add("sys", "〔奖励结算〕"); loc = advance(loc); }
else if (k === "out_ref") { add("sys", "〔进入子序列 " + n.ref + "〕"); loc = enterSeq(n.ref, n.next); }
else if (k === "choice" || k === "choice_once") { renderChoice(n); return; }
else if (k === "random") { renderRandom(n); return; }
else if (k === "fight") { renderFight(n); return; }
else { add("sys", "未知节点类型 " + k); loc = advance(loc); }
}
if (guard >= 500) add("sys", "⚠ 步数超限(疑似循环),中断");
}
function choices(prompt, list) {
const box = document.createElement("div"); box.className = "pt-step pt-choices";
box.appendChild(Object.assign(document.createElement("div"), { className: "pt-q", textContent: prompt }));
list.forEach(it => {
const b = document.createElement("button");
b.textContent = it.label; if (it.locked) b.className = "locked";
b.onclick = () => { box.querySelectorAll("button").forEach(x => x.disabled = true); b.style.borderColor = "#e6c878"; it.act(); };
box.appendChild(b);
});
flow.appendChild(box); flow.scrollTop = flow.scrollHeight;
}
function renderChoice(n) {
choices("请选择:", (n.options || []).map(o => {
const ok = condMet(o.condition);
let label = o.text;
if (o.condition) label += " [需 " + o.condition.kind + o.condition.op + o.condition.value + (ok ? " ✓" : " ✗") + "]";
if (o.skip) label += " 〔押注跳过〕";
return {
label, locked: !ok, act: () => {
applyGrants((o.reward || {}).grants, "选项");
if (o.skip) pendingSkip = { node: o.skip.node, grants: (o.skip.reward || {}).grants || [] };
walk({ seq: null, id: o.goto });
}
};
}));
}
function renderRandom(n) {
const total = (n.branches || []).reduce((s, b) => s + (b.weight || 0), 0) || 1;
choices("随机分支(手选):", (n.branches || []).map((b, i) => ({
label: "分支" + (i + 1) + "(权重 " + b.weight + ",约 " + Math.round(b.weight / total * 100) + "%)→ " + b.goto,
act: () => walk({ seq: null, id: b.goto })
})));
}
function renderFight(n) {
choices("战斗 vs " + (n.camp2 || []).map(nameOf).join("、") + " —— 手选结果:", [
{ label: "胜 → " + n.win, act: () => walk({ seq: null, id: n.win }) },
{ label: "败 → " + n.lose, act: () => walk({ seq: null, id: n.lose }) },
]);
}
function firstNode() {
const indeg = {}; (IR.nodes || []).forEach(n => indeg[n.id] = 0);
(IR.nodes || []).forEach(n => {
const outs = [n.next].concat((n.options || []).map(o => o.goto), (n.branches || []).map(b => b.goto), n.kind === "fight" ? [n.win, n.lose] : []);
outs.forEach(t => { if (t in indeg) indeg[t]++; });
});
const roots = (IR.nodes || []).filter(n => indeg[n.id] === 0);
return (roots[0] || (IR.nodes || [])[0] || {}).id;
}
window.Playtest = {
open(ir, dict) {
IR = ir; DICT = dict;
flow = document.getElementById("pt-flow"); ledger = document.getElementById("pt-ledger");
flow.innerHTML = ""; state = { 银两: 0, 道具: {}, 友好度: {}, 入门: [] };
retStack = []; pendingSkip = null;
renderLedger();
document.getElementById("playtest-modal").classList.remove("hidden");
const start = firstNode();
if (!start) { add("sys", "没有可走的节点"); return; }
walk({ seq: null, id: start });
}
};
})();