- 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补齐)
172 lines
8.2 KiB
JavaScript
172 lines
8.2 KiB
JavaScript
// 剧本试走:从首节点走,点选项/掷随机/手选战斗胜负,模拟银两/道具/友好度账面。
|
||
// 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, "<").replace(/>/g, ">"); }
|
||
|
||
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 });
|
||
}
|
||
};
|
||
})();
|