Files
story-edit-web/web/static/playtest.js
邓雨鹏 90402c4a17 security: 每人一把口令(口令即身份) + 随机会话token + 无配置拒绝启动 + 爆破节流
- STORY_WEB_PASSWORD(默认story) 废弃 → STORY_WEB_USERS=名字1:口令1,名字2:口令2;
  未配置/口令<8位/口令或用户名重复 → 启动即退出,杜绝弱默认口令裸奔
- cookie 不再存口令原文:登录发 secrets.token_urlsafe(32) 随机token,
  会话存 SQLite sessions 表(30天);登出删token;从 USERS 移除某人=吊销其全部会话
- updated_by 改由服务端按会话身份填写,前端自报 by 不再可信;登录框去掉昵称字段
- 登录失败全局递增节流(最多sleep 5s),口令比较用 secrets.compare_digest
- Dockerfile/compose 移除一切口令默认值;compose 未设 STORY_WEB_USERS 直接报错
- 顺手修 playtest.js 走位/动画/out_ref 行未转义的存储型XSS(esc补齐)
2026-06-10 17:34:50 +08:00

172 lines
8.2 KiB
JavaScript
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.

// 剧本试走:从首节点走,点选项/掷随机/手选战斗胜负,模拟银两/道具/友好度账面。
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function renderLedger() {
let h = "<h4>账面</h4>";
h += '<div class="lg">银两:<b>' + state.银两 + "</b></div>";
const items = Object.entries(state.道具).filter(([, v]) => v);
h += '<div class="lg">道具:' + (items.length ? items.map(([k, v]) => k + "×" + v).join("") : "—") + "</div>";
const fr = Object.entries(state.友好度).filter(([, v]) => v);
h += '<div class="lg">友好度:' + (fr.length ? fr.map(([k, v]) => nameOf(k) + "+" + v).join("") : "—") + "</div>";
if (state.入门.length) h += '<div class="lg">入门:' + state.入门.join("") + "</div>";
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", "<b>" + esc(nameOf(n.speaker || "P1")) + "</b>" + esc(n.text)); loc = advance(loc); }
else if (k === "dialogue") { add("spk", "<b>" + esc(nameOf(n.speaker)) + "</b>" + esc(n.text)); loc = advance(loc); }
else if (k === "move") { add("sys", "〔走位〕" + esc(nameOf(n.actor)) + " → " + esc(n.to || "")); loc = advance(loc); }
else if (k === "anim") { add("sys", "〔动画〕" + esc(nameOf(n.actor)) + " " + esc(n.ani || "")); loc = advance(loc); }
else if (k === "reward") { applyGrants(n.grants, "结算"); add("sys", "〔奖励结算〕"); loc = advance(loc); }
else if (k === "out_ref") { add("sys", "〔进入子序列 " + esc(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 });
}
};
})();