fix(core,studio): player Number() parity with evaluator + log skipped export images

This commit is contained in:
Ma
2026-06-27 00:09:21 +08:00
parent 0ec7c1ffc3
commit ae541f0a43
3 changed files with 22 additions and 4 deletions
@@ -44,6 +44,24 @@ describe("buildPlayableHtml", () => {
expect(html).toContain('<script>BAD</script>');
});
// Determinism parity: relational ops must Number()-coerce both operands (mirrors evaluator.ts)
it("player evalCond uses Number() coercion for relational ops (parity with evaluator)", () => {
const html = buildPlayableHtml(graph);
// All four relational ops must use Number(v) and Number(c.value)
expect(html).toMatch(/Number\(v\)\s*>=\s*Number\(c\.value\)/);
expect(html).toMatch(/Number\(v\)\s*<=\s*Number\(c\.value\)/);
expect(html).toMatch(/Number\(v\)\s*>\s*Number\(c\.value\)/);
expect(html).toMatch(/Number\(v\)\s*<\s*Number\(c\.value\)/);
// Raw lexical comparison must not appear for relational ops
expect(html).not.toMatch(/return v>=c\.value/);
expect(html).not.toMatch(/return v<=c\.value/);
expect(html).not.toMatch(/return v>c\.value/);
expect(html).not.toMatch(/return v<c\.value/);
// add/sub effects must Number()-coerce
expect(html).toMatch(/Number\(vars\[e\.var\]\|\|0\)\+Number\(e\.value\)/);
expect(html).toMatch(/Number\(vars\[e\.var\]\|\|0\)-Number\(e\.value\)/);
});
// XSS: Fix 2 — node content embedded in JSON is escaped by esc() so the raw onerror attribute never appears
it("does not embed raw onerror attribute from node content (Fix 2)", () => {
const xssGraph = StoryGraphSchema.parse({
@@ -8,9 +8,9 @@ const PLAYER_JS = String.raw`
var nodeById = {}; (GRAPH.nodes||[]).forEach(function(n){ nodeById[n.id] = n; });
var endingByNode = {}; (GRAPH.endings||[]).forEach(function(e){ endingByNode[e.nodeId] = e; });
function evalCond(c){ if(!c) return true; var v = vars[c.var];
switch(c.op){ case ">=": return v>=c.value; case "<=": return v<=c.value; case ">": return v>c.value; case "<": return v<c.value; case "==": return v===c.value; case "!=": return v!==c.value; } return true; }
switch(c.op){ case ">=": return Number(v)>=Number(c.value); case "<=": return Number(v)<=Number(c.value); case ">": return Number(v)>Number(c.value); case "<": return Number(v)<Number(c.value); case "==": return v===c.value; case "!=": return v!==c.value; } return true; }
function applyEffects(effects){ (effects||[]).forEach(function(e){
if(e.op==="add") vars[e.var]=(vars[e.var]||0)+e.value; else if(e.op==="sub") vars[e.var]=(vars[e.var]||0)-e.value; else vars[e.var]=e.value; }); }
if(e.op==="add") vars[e.var]=Number(vars[e.var]||0)+Number(e.value); else if(e.op==="sub") vars[e.var]=Number(vars[e.var]||0)-Number(e.value); else vars[e.var]=e.value; }); }
function visible(node){ return (node.choices||[]).filter(function(c){ return evalCond(c.condition); }); }
var root = document.getElementById("if-player");
function hud(){ var s = Object.keys(vars).map(function(k){ return h(k)+": "+h(String(vars[k])); }).join(" · "); return s ? '<div class="hud">'+s+'</div>' : ''; }
+2 -2
View File
@@ -5270,8 +5270,8 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
const file = resolveProjectImageFile(root, ref);
const buf = await readFile(file.resolved);
assetDataUris[ref] = `data:${file.contentType};base64,${buf.toString("base64")}`;
} catch {
/* missing or unreadable image — skip this assetRef, bundle is still generated */
} catch (err) {
console.warn(`[studio] export/html: skipping assetRef "${ref}" —`, err);
}
}
return new Response(buildPlayableHtml(graph, { assetDataUris }), {