fix: 首启同意门(consent 事件存证)+ 条件渲染系统(flags 推导 27 变量)+ 10 类内容-界面不一致修复
This commit is contained in:
@@ -16,7 +16,7 @@
|
|||||||
"id": "c1-open2",
|
"id": "c1-open2",
|
||||||
"type": "dialogue",
|
"type": "dialogue",
|
||||||
"speaker": "旁白",
|
"speaker": "旁白",
|
||||||
"text": "显示器右下角,弹出一封没有发件人的邮件。只有一行字:「你想不想听听它自己怎么说?——时空档案馆」。你盯着那行字看了很久。窗外雨声很大,像很多人在很远的地方说话。(点击确认即视为知晓:剧情选择将用于为你推荐伴侣。按本心选,没有对错——此提示全剧只出现这一次。)",
|
"text": "显示器右下角,弹出一封没有发件人的邮件。只有一行字:「你想不想听听它自己怎么说?——时空档案馆」。你盯着那行字看了很久。窗外雨声很大,像很多人在很远的地方说话。",
|
||||||
"sprite": "scene",
|
"sprite": "scene",
|
||||||
"next": "c1-d1"
|
"next": "c1-d1"
|
||||||
},
|
},
|
||||||
@@ -278,7 +278,7 @@
|
|||||||
"id": "c1-05",
|
"id": "c1-05",
|
||||||
"type": "choice",
|
"type": "choice",
|
||||||
"pointId": "C1-05",
|
"pointId": "C1-05",
|
||||||
"prompt": "碑额拓片特写:上方是屋顶(宀),屋顶之下一个安坐的人形,膝前有衣袂垂落之态——宀下一个女子安坐的形象。这是什么字?它刻在碑额上,意味着这是什么碑?(可主动求提示,每用一档降一档解谜评分,不设失败:一档拆字、二档给构件含义、三档给答案范围。)",
|
"prompt": "碑额拓片特写:上方是屋顶(宀),屋顶之下一个安坐的人形,膝前有衣袂垂落之态——宀下一个女子安坐的形象。这是什么字?它刻在碑额上,意味着这是什么碑?",
|
||||||
"dimensions": ["culture"],
|
"dimensions": ["culture"],
|
||||||
"options": [
|
"options": [
|
||||||
{ "id": "stele_guardian", "label": "「安」。室中有女,安坐不惊——这是镇城安民碑,碑额朝向即古城安门所在。", "next": "c1-05-ok" },
|
{ "id": "stele_guardian", "label": "「安」。室中有女,安坐不惊——这是镇城安民碑,碑额朝向即古城安门所在。", "next": "c1-05-ok" },
|
||||||
|
|||||||
@@ -8,7 +8,15 @@
|
|||||||
"id": "c2-d1",
|
"id": "c2-d1",
|
||||||
"type": "dialogue",
|
"type": "dialogue",
|
||||||
"speaker": "旁白",
|
"speaker": "旁白",
|
||||||
"text": "【落点变体 · 若 c1_06_landing = a_safe(稳妥落点)】清晨,城外官道。薄雾,黄土路被夜雨泡软。你研究过一千年的苍梧,可当它真的立在雾里的时候,你第一个念头竟然是:原来城墙上的夯土层是这个颜色。驿站里,客商们在传一件事:城里从上月起,夜里不许点灯了。官府没说为什么。有个老客商压低声音:「听说是宫里传出来的。天上有东西不对。」——【落点变体 · 若 c1_06_landing = b_precise(精确落点)】黄昏,苍梧城内西市,满城无灯。落点舱的余震还在耳膜里,你已经站在了一千年前的苍梧城里。家家户户门窗紧闭,街上的人走路都贴着墙根。一个卖炊饼的老汉收摊时看了你一眼,那一眼不是好奇,是数你。「苍梧最后一个月,夜不举火。」人在怕。只是不知道在怕什么。",
|
"text": "【若 c1_06_landing = a_safe】清晨,城外官道。薄雾,黄土路被夜雨泡软,车辙里积着水,远处苍梧城墙的轮廓在雾里浮出来,比任何复原图都高。你研究过一千年的苍梧,可当它真的立在雾里的时候,你第一个念头竟然是:原来城墙上的夯土层是这个颜色。驿站里,客商们在传一件事:城里从上月起,夜里不许点灯了。官府没说为什么。有个老客商压低声音:「听说是宫里传出来的。天上有东西不对。」",
|
||||||
|
"sprite": "scene",
|
||||||
|
"next": "c2-d1v"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c2-d1v",
|
||||||
|
"type": "dialogue",
|
||||||
|
"speaker": "旁白",
|
||||||
|
"text": "【若 c1_06_landing = b_precise】黄昏,苍梧城内西市。西市正收摊,灯笼却没亮——满城无灯。落点舱的余震还在耳膜里,你已经站在了一千年前的苍梧城里。没有人看见你出现,但你觉得整座城都看见了:家家户户门窗紧闭,街上的人走路都贴着墙根。一个卖炊饼的老汉收摊时看了你一眼,那一眼不是好奇,是数你。天上最后一线光里,你忽然明白资料里那句话是什么意思:「苍梧最后一个月,夜不举火。」人在怕。只是不知道在怕什么。",
|
||||||
"sprite": "scene",
|
"sprite": "scene",
|
||||||
"next": "c2-d1a"
|
"next": "c2-d1a"
|
||||||
},
|
},
|
||||||
@@ -16,7 +24,7 @@
|
|||||||
"id": "c2-d1a",
|
"id": "c2-d1a",
|
||||||
"type": "dialogue",
|
"type": "dialogue",
|
||||||
"speaker": "旁白",
|
"speaker": "旁白",
|
||||||
"text": "(两变体汇合处)小队按预定暗号在城西水门内会合,建立营地。沈铎分派:明日采集开始。",
|
"text": "小队按预定暗号在城西水门内会合,建立营地。沈铎分派:明日采集开始。",
|
||||||
"sprite": "scene",
|
"sprite": "scene",
|
||||||
"next": "c2-d2"
|
"next": "c2-d2"
|
||||||
},
|
},
|
||||||
@@ -245,7 +253,7 @@
|
|||||||
"options": [
|
"options": [
|
||||||
{ "id": "a_main", "label": "「走主街。」(支持沈铎:四十分钟买确定性,值)", "next": "c2-03-ra" },
|
{ "id": "a_main", "label": "「走主街。」(支持沈铎:四十分钟买确定性,值)", "next": "c2-03-ra" },
|
||||||
{ "id": "b_alley", "label": "「走巷子。」(支持阿棠:饮水和时间是真约束,悬梁看着还稳)", "next": "c2-03-rb" },
|
{ "id": "b_alley", "label": "「走巷子。」(支持阿棠:饮水和时间是真约束,悬梁看着还稳)", "next": "c2-03-rb" },
|
||||||
{ "id": "c_scout", "label": "「巷子我先进。我体重最轻,走前面探,有问题退回来,主力走主街汇合。」(折中:用自己去验证风险——剧本写定:选此项必然塌梁,置 flag_fresh_collapse)", "next": "c2-03-rc" }
|
{ "id": "c_scout", "label": "「巷子我先进。我体重最轻,走前面探,有问题退回来,主力走主街汇合。」(折中:用自己去验证风险)", "next": "c2-03-rc" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -268,7 +276,7 @@
|
|||||||
"id": "c2-03-rc",
|
"id": "c2-03-rc",
|
||||||
"type": "dialogue",
|
"type": "dialogue",
|
||||||
"speaker": "旁白",
|
"speaker": "旁白",
|
||||||
"text": "你先进巷子,中段一根悬梁真的塌了——提前预警,你扑出来,灰头土脸,人没事。全队退回走主街,损失二十分钟。阿棠主动说「是我撺掇的」,沈铎说「探法本身没错,下次用绳」。你获得一条独有观察:塌梁的断口是新茬——这片废墟不久前塌过一次。(flag_fresh_collapse = true,宫墟深处会回收这条观察。)",
|
"text": "你先进巷子,中段一根悬梁真的塌了——提前预警,你扑出来,灰头土脸,人没事。全队退回走主街,损失二十分钟。阿棠主动说「是我撺掇的」,沈铎说「探法本身没错,下次用绳」。你获得一条独有观察:塌梁的断口是新茬——这片废墟不久前塌过一次。记住这个细节,往下走,你会再想起它。",
|
||||||
"sprite": "scene",
|
"sprite": "scene",
|
||||||
"next": "c2-d5"
|
"next": "c2-d5"
|
||||||
},
|
},
|
||||||
@@ -300,7 +308,7 @@
|
|||||||
"id": "c2-04",
|
"id": "c2-04",
|
||||||
"type": "choice",
|
"type": "choice",
|
||||||
"pointId": "C2-04",
|
"pointId": "C2-04",
|
||||||
"prompt": "星图盘外圈完整刻着东方苍龙七宿(角、亢、氐、房、心、尾、箕)的连线,被磨去的中央区域正是苍龙的「心脏」。盘面残留一行小字铭文:「火中,而后城安。」补全被磨去的部分——缺的是哪一宿?「大火」又是哪一颗?(可求提示:一档指数数缺哪宿、二档解铭文钥匙、三档给答案。)",
|
"prompt": "星图盘外圈完整刻着东方苍龙七宿(角、亢、氐、房、心、尾、箕)的连线,被磨去的中央区域正是苍龙的「心脏」。盘面残留一行小字铭文:「火中,而后城安。」补全被磨去的部分——缺的是哪一宿?「大火」又是哪一颗?",
|
||||||
"dimensions": ["culture"],
|
"dimensions": ["culture"],
|
||||||
"options": [
|
"options": [
|
||||||
{ "id": "heart_xin", "label": "心宿三星,排成一线微弯;中间最红最亮的那颗是大火——心宿二。把它点在苍龙的心口。", "next": "c2-04-ok" },
|
{ "id": "heart_xin", "label": "心宿三星,排成一线微弯;中间最红最亮的那颗是大火——心宿二。把它点在苍龙的心口。", "next": "c2-04-ok" },
|
||||||
@@ -393,7 +401,7 @@
|
|||||||
"olfactory_gustatory": "那段日子有什么气味或味道,留在记忆里了吗?"
|
"olfactory_gustatory": "那段日子有什么气味或味道,留在记忆里了吗?"
|
||||||
},
|
},
|
||||||
"variants": [
|
"variants": [
|
||||||
{ "signature": "深暴露", "variantId": "wenjian_close_early", "label": "【同担】温简听完后沉默很久,然后讲了第二个、剧本外的细节——他哥病房的窗帘颜色。当夜他低声告诉你:档案馆给每个记录员配的板子,上报内容比你知道的多一行。基调:两个把伤口摊开的人,在火边达成不声张的同盟。", "tint": "#d4844b" },
|
{ "signature": "深暴露", "variantId": "wenjian_close_early", "label": "【同担】温简听完后沉默很久,然后讲了第二个、从没对任何人讲过的细节——他哥病房的窗帘颜色。当夜他低声告诉你:档案馆给每个记录员配的板子,上报内容比你知道的多一行。基调:两个把伤口摊开的人,在火边达成不声张的同盟。", "tint": "#d4844b" },
|
||||||
{ "signature": "浅暴露", "variantId": "wenjian_close_late", "label": "【半程】温简认真听完,道谢,没再追问。次日他与你同行,路上主动讲了些苍梧的掌故。档案馆疑云他没提。基调:得体,温热,留有余地。", "tint": "#8a7a5a" },
|
{ "signature": "浅暴露", "variantId": "wenjian_close_late", "label": "【半程】温简认真听完,道谢,没再追问。次日他与你同行,路上主动讲了些苍梧的掌故。档案馆疑云他没提。基调:得体,温热,留有余地。", "tint": "#8a7a5a" },
|
||||||
{ "signature": "沉默", "variantId": "wenjian_distant", "label": "【守夜】你们并肩坐到火熄。温简往你手里塞了个烤热的薯,什么都没说。次日他照旧对你点头,不多一分不少一分——但阿棠偷偷告诉你:「温简昨晚跟沈铎说,你这个人『稳』。」基调:沉默被当作一种讲述接住了;温简的亲密度走慢线。", "tint": "#4b4b5a" }
|
{ "signature": "沉默", "variantId": "wenjian_distant", "label": "【守夜】你们并肩坐到火熄。温简往你手里塞了个烤热的薯,什么都没说。次日他照旧对你点头,不多一分不少一分——但阿棠偷偷告诉你:「温简昨晚跟沈铎说,你这个人『稳』。」基调:沉默被当作一种讲述接住了;温简的亲密度走慢线。", "tint": "#4b4b5a" }
|
||||||
],
|
],
|
||||||
@@ -447,7 +455,7 @@
|
|||||||
"id": "c2-06-rb",
|
"id": "c2-06-rb",
|
||||||
"type": "dialogue",
|
"type": "dialogue",
|
||||||
"speaker": "老鬼",
|
"speaker": "老鬼",
|
||||||
"text": "次日私下对质。老鬼收起笑,盯了你三秒,忽然又笑了:「新人,你比看上去老练。」他把玉佩交给你,让你转交登记,临走撂下一句:「这队里不止我一个人手上不干净,你慢慢看。」(沈铎收到玉佩时问来路,你替他圆了——或者没有:剧本的二级选项,记 c2_06_cover,引擎暂不建模。)",
|
"text": "次日私下对质。老鬼收起笑,盯了你三秒,忽然又笑了:「新人,你比看上去老练。」他把玉佩交给你,让你转交登记,临走撂下一句:「这队里不止我一个人手上不干净,你慢慢看。」沈铎收到玉佩时问了来路——替他圆,还是不圆,那一刻是你自己拿的主意。",
|
||||||
"sprite": "laogui",
|
"sprite": "laogui",
|
||||||
"next": "c2-d8"
|
"next": "c2-d8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"id": "c3-d1a",
|
"id": "c3-d1a",
|
||||||
"type": "dialogue",
|
"type": "dialogue",
|
||||||
"speaker": "旁白",
|
"speaker": "旁白",
|
||||||
"text": "【若 flag_fresh_collapse = true(C2-03 你亲自探路)】台阶转角处,你又见到了那种新茬——塌过的夯土,被人重新夯实过。不久之前,有人在这里修过路。往下走。城在等的那一天到来之前,已经有人急着下去了。",
|
"text": "【若 flag_fresh_collapse = true】台阶转角处,你又见到了那种新茬——塌过的夯土,被人重新夯实过。不久之前,有人在这里修过路。往下走。城在等的那一天到来之前,已经有人急着下去了。",
|
||||||
"sprite": "scene",
|
"sprite": "scene",
|
||||||
"next": "c3-d1b"
|
"next": "c3-d1b"
|
||||||
},
|
},
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"id": "c3-01",
|
"id": "c3-01",
|
||||||
"type": "choice",
|
"type": "choice",
|
||||||
"pointId": "C3-01",
|
"pointId": "C3-01",
|
||||||
"prompt": "台基上器物分四区:盘与盉(盥洗之器)、爵与尊(酒器)、鼎七件簋六件(盛牺牲黍稷)、最深处一座空着的玉璧座(礼天以璧)。① 还原仪式流程的先后顺序;② 空璧座意味着什么?(可求提示:一档按用途分类、二档给列鼎制度与「先洁后献」、三档给答案。)",
|
"prompt": "台基上器物分四区:盘与盉(盥洗之器)、爵与尊(酒器)、鼎七件簋六件(盛牺牲黍稷)、最深处一座空着的玉璧座(礼天以璧)。还原仪式流程的先后顺序,并回答:空璧座意味着什么?",
|
||||||
"dimensions": ["culture"],
|
"dimensions": ["culture"],
|
||||||
"options": [
|
"options": [
|
||||||
{ "id": "rite_correct", "label": "沃盥 → 献酒 → 荐食 → 奉璧。七鼎六簋,主祭是诸侯级的国君——这是国祭;璧座是空的:献给天的那件礼,始终没拿出来。或者,拿不出来。", "next": "c3-01-ok" },
|
{ "id": "rite_correct", "label": "沃盥 → 献酒 → 荐食 → 奉璧。七鼎六簋,主祭是诸侯级的国君——这是国祭;璧座是空的:献给天的那件礼,始终没拿出来。或者,拿不出来。", "next": "c3-01-ok" },
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
"id": "c3-cutscene",
|
"id": "c3-cutscene",
|
||||||
"type": "cutscene",
|
"type": "cutscene",
|
||||||
"video": "cutscene-illusion-clear",
|
"video": "cutscene-illusion-clear",
|
||||||
"caption": "黑石的画面缓缓沉下去,像深水重新凝住。石面最后亮了一下——映出的是你自己现在的脸。然后石室另一侧,一扇先前不存在的石门,无声滑开。馆长:「门开了。它不要你的记忆,它要的是你承认那是你的。这段不登记入档。问心之室里的事,归你。」【若你选择退出石室,石门同样滑开,馆长台词换为:「不出来的人,档案馆不催。你来了,看了,走了——这也是告别的一种。」】",
|
"caption": "黑石的画面缓缓沉下去,像深水重新凝住。石面最后亮了一下——映出的是你自己现在的脸。然后石室另一侧,一扇先前不存在的石门,无声滑开。馆长:「门开了。它不要你的记忆,它要的是你承认那是你的。这段不登记入档。问心之室里的事,归你。」【若 c3_02_mode = exit_room:你最终没有走进去。石门同样滑开。馆长:「不出来的人,档案馆不催。你来了,看了,走了——这也是告别的一种。」】",
|
||||||
"next": "c3-d3"
|
"next": "c3-d3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
"id": "c3-d4b",
|
"id": "c3-d4b",
|
||||||
"type": "dialogue",
|
"type": "dialogue",
|
||||||
"speaker": "阿棠",
|
"speaker": "阿棠",
|
||||||
"text": "哎你少拉别人下水!【若 c2_06_jade = a_public(你当初当场揭发玉佩)】上次的玉佩也是你吧?手痒?我看你是手黑!【若 c2_06_jade = b_private / c_watch】(她忽然停住,看向你——她不知想起了什么,但没有说破。你只有自己知道,你和她此刻想的,是不是同一件事。)",
|
"text": "哎你少拉别人下水!【若 c2_06_jade = a_public:上次的玉佩也是你吧?手痒?我看你是手黑!】【若 c2_06_jade != a_public:(她忽然停住,看向你——她不知想起了什么,但没有说破。你只有自己知道,你和她此刻想的,是不是同一件事。)】",
|
||||||
"sprite": "atang",
|
"sprite": "atang",
|
||||||
"next": "c3-d4c"
|
"next": "c3-d4c"
|
||||||
},
|
},
|
||||||
@@ -388,7 +388,7 @@
|
|||||||
"id": "c3-d6c",
|
"id": "c3-d6c",
|
||||||
"type": "dialogue",
|
"type": "dialogue",
|
||||||
"speaker": "旁白",
|
"speaker": "旁白",
|
||||||
"text": "【若 c1_03_motive = d_trust(你的动机是「有人希望我去」)】通话末尾还有一句,声音更低,几乎不像在通话,像在自言自语:「……是的,托付那类,最不好办。」",
|
"text": "【若 c1_03_motive = d_trust】通话末尾还有一句,声音更低,几乎不像在通话,像在自言自语:「……是的,托付那类,最不好办。」",
|
||||||
"sprite": "scene",
|
"sprite": "scene",
|
||||||
"next": "c3-d6d"
|
"next": "c3-d6d"
|
||||||
},
|
},
|
||||||
@@ -404,14 +404,14 @@
|
|||||||
"id": "c3-06",
|
"id": "c3-06",
|
||||||
"type": "choice",
|
"type": "choice",
|
||||||
"pointId": "C3-06",
|
"pointId": "C3-06",
|
||||||
"prompt": "档案馆的隐瞒,你打算怎么办?(剧本的二级选项「叫醒谁」并入本点:选哪个队友,即向谁倾诉。)",
|
"prompt": "档案馆的隐瞒,你打算怎么办?(叫醒队友的话——叫醒谁,就向谁倾诉。)",
|
||||||
"dimensions": ["attachment", "mate_preference"],
|
"dimensions": ["attachment", "mate_preference"],
|
||||||
"options": [
|
"options": [
|
||||||
{ "id": "a_confront", "label": "(按下通讯键,直接问馆长)「刚才那段通话。问心石是你们放的?璧是什么?——现在,跟我说实话。」", "next": "c3-06-ra" },
|
{ "id": "a_confront", "label": "(按下通讯键,直接问馆长)「刚才那段通话。问心石是你们放的?璧是什么?——现在,跟我说实话。」", "next": "c3-06-ra" },
|
||||||
{ "id": "b_conceal", "label": "(退回营地,躺平,盯着黑暗)按下不表。听到的每一个字都记住。在弄清楚「他们为什么要瞒」之前,装作没听过。", "next": "c3-06-rb" },
|
{ "id": "b_conceal", "label": "(退回营地,躺平,盯着黑暗)按下不表。听到的每一个字都记住。在弄清楚「他们为什么要瞒」之前,装作没听过。", "next": "c3-06-rb" },
|
||||||
{ "id": "c_confide_shenduo", "label": "(叫醒沈铎,把听到的一切说出来)", "next": "c3-06-rc1" },
|
{ "id": "c_confide_shenduo", "label": "(叫醒沈铎,把听到的一切说出来)", "next": "c3-06-rc1" },
|
||||||
{ "id": "c_confide_atang", "label": "(叫醒阿棠,把听到的一切说出来)", "next": "c3-06-rc2" },
|
{ "id": "c_confide_atang", "label": "(叫醒阿棠,把听到的一切说出来)", "next": "c3-06-rc2" },
|
||||||
{ "id": "c_confide_wenjian", "label": "(叫醒温简,把听到的一切说出来)【若 flag_wenjian_secret(篝火边他告诉你板子的秘密):温简说过,记录板上报的内容比你多一行。他可能知道些什么。】", "next": "c3-06-rc3" }
|
{ "id": "c_confide_wenjian", "label": "(叫醒温简,把听到的一切说出来)【若 flag_wenjian_secret = true:温简说过,记录板上报的内容比你多一行。他可能知道些什么。】", "next": "c3-06-rc3" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+105
-2
@@ -6,12 +6,15 @@
|
|||||||
* 4. 追问补全流程:缺失检测 → 追问事件 / 跳过事件("记不清了"合法)→ 回答合并进讲述文本;
|
* 4. 追问补全流程:缺失检测 → 追问事件 / 跳过事件("记不清了"合法)→ 回答合并进讲述文本;
|
||||||
* 5. EventStore 各实现(含 SqliteEventStore)的 append-only 性质(接口层面断言无 update/delete;
|
* 5. EventStore 各实现(含 SqliteEventStore)的 append-only 性质(接口层面断言无 update/delete;
|
||||||
* SQLite 实连只能在 Tauri 运行时内进行,Node 下不实例化)。
|
* SQLite 实连只能在 Tauri 运行时内进行,Node 下不实例化)。
|
||||||
|
* 6. 同意门(ADR-0004):consent 事件存证、幂等、跨会话跳过;
|
||||||
|
* 7. 条件文本:flag 推导(附录 A)、【若】标记零泄漏、差分显隐、条件不满足节点跳过无死路。
|
||||||
* 运行:pnpm verify
|
* 运行:pnpm verify
|
||||||
*/
|
*/
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { StoryEngine } from '../src/engine/engine';
|
import { CONSENT_VERSION, StoryEngine } from '../src/engine/engine';
|
||||||
import { LocalStorageEventStore } from '../src/engine/eventStore';
|
import { LocalStorageEventStore } from '../src/engine/eventStore';
|
||||||
import type { EventStore, StoryEvent } from '../src/engine/eventStore';
|
import type { EventStore, StoryEvent } from '../src/engine/eventStore';
|
||||||
|
import { choiceFlags, narrationFlags } from '../src/engine/flags';
|
||||||
import { SqliteEventStore } from '../src/engine/sqliteEventStore';
|
import { SqliteEventStore } from '../src/engine/sqliteEventStore';
|
||||||
import type { Chapter, ChoiceNode, NarrationNode } from '../src/engine/types';
|
import type { Chapter, ChoiceNode, NarrationNode } from '../src/engine/types';
|
||||||
import { FOLLOW_UP_DIMENSIONS } from '../src/engine/types';
|
import { FOLLOW_UP_DIMENSIONS } from '../src/engine/types';
|
||||||
@@ -294,8 +297,108 @@ for (const cls of [LocalStorageEventStore, SqliteEventStore, MemoryStore]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 9. 同意门(ADR-0004):consent 事件存证、幂等、跨会话跳过 ---
|
||||||
|
{
|
||||||
|
const s = new MemoryStore();
|
||||||
|
const eng = new StoryEngine(chapters, s, new MockJevClient());
|
||||||
|
check(!(await eng.hasConsent()), '同意门:无 consent 事件时需要展示同意门');
|
||||||
|
await eng.grantConsent();
|
||||||
|
const ev = s.events.find((e) => e.kind === 'consent');
|
||||||
|
check(
|
||||||
|
!!ev && ev.consentVersion === CONSENT_VERSION && ev.ts > 0,
|
||||||
|
`同意动作落 consent 事件(consentVersion=${ev?.consentVersion},含时间戳)`,
|
||||||
|
);
|
||||||
|
await eng.grantConsent();
|
||||||
|
check(s.events.filter((e) => e.kind === 'consent').length === 1, '重复同意幂等(只存证一次)');
|
||||||
|
const eng2 = new StoryEngine(chapters, s, new MockJevClient());
|
||||||
|
check(await eng2.hasConsent(), 'consent 事件已存在则同意门跳过(全剧只出现一次)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 10. 条件文本:flag 推导、标记不泄漏、差分显隐、空节点跳过 ---
|
||||||
|
// 10a. 变量映射表(附录 A)
|
||||||
|
check(choiceFlags('C2-03', 'c_scout').flag_fresh_collapse === 'true', 'flag 推导:C2-03 c_scout → flag_fresh_collapse=true');
|
||||||
|
check(choiceFlags('C2-01', 'b_exposure').wenjian_health === 'weak', 'flag 推导:C2-01 b_exposure → wenjian_health=weak');
|
||||||
|
check(choiceFlags('C2-01', 'a_weakest').wenjian_health === 'recovering', 'flag 推导:C2-01 a_weakest → wenjian_health=recovering');
|
||||||
|
check(narrationFlags('C3-02', undefined, null).c3_02_mode === 'exit_room', 'flag 推导:C3-02 沉默退出 → c3_02_mode=exit_room');
|
||||||
|
{
|
||||||
|
const f = choiceFlags('C3-06', 'c_confide_wenjian');
|
||||||
|
check(
|
||||||
|
f.c3_06_response === 'c_confide' && f.c3_06_confidant === 'wenjian' && f.flag_arch_doubt === 'true',
|
||||||
|
'flag 推导:C3-06 队友子分支 → c_confide / confidant=wenjian / flag_arch_doubt',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
check(narrationFlags('C2-05', 'wenjian_close_early', '(讲述)').flag_wenjian_secret === 'true', 'flag 推导:C2-05 深暴露 → flag_wenjian_secret');
|
||||||
|
|
||||||
|
/** 走查三章并收集途经节点与渲染文本;picker 可指定选择点的选项 */
|
||||||
|
async function walkAll(eng: StoryEngine, picker: Record<string, string> = {}) {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const rendered: string[] = [];
|
||||||
|
let guard = 0;
|
||||||
|
while (!eng.state.finished && guard++ < 250) {
|
||||||
|
const node = eng.currentNode();
|
||||||
|
seen.push(node.id);
|
||||||
|
if (node.type === 'dialogue') rendered.push(eng.renderText(node.text));
|
||||||
|
else if (node.type === 'cutscene') rendered.push(eng.renderText(node.caption));
|
||||||
|
else if (node.type === 'choice') rendered.push(eng.renderText(node.prompt), ...node.options.map((o) => eng.renderText(o.label)));
|
||||||
|
else if (node.type === 'narration' || node.type === 'identity') rendered.push(eng.renderText(node.prompt));
|
||||||
|
if (node.type === 'choice') {
|
||||||
|
await eng.choose(picker[node.pointId] ?? node.options[0].id);
|
||||||
|
} else if (node.type === 'identity') {
|
||||||
|
await eng.confirmIdentity(await eng.extractIdentity('占位自我介绍'));
|
||||||
|
} else if (node.type === 'narration') {
|
||||||
|
if (node.pointId === 'C3-02' && picker.C3_02_SILENT) await eng.skipNarration();
|
||||||
|
else {
|
||||||
|
await eng.submitNarration('(验证用占位讲述)');
|
||||||
|
if (eng.state.pendingFollowUp) await eng.answerFollowUp('(验证用追问回答)');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eng.advance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { seen, rendered, finished: eng.state.finished };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10b. 默认路径(全取首选项):标记零泄漏 + 条件节点按 flag 跳过
|
||||||
|
{
|
||||||
|
const s = new MemoryStore();
|
||||||
|
const eng = new StoryEngine(chapters, s, new MockJevClient());
|
||||||
|
const { seen, rendered, finished } = await walkAll(eng);
|
||||||
|
check(finished, '条件系统下默认路径三章跑通');
|
||||||
|
check(rendered.every((t) => !t.includes('【若')), '渲染输出零【若】标记泄漏(默认路径)');
|
||||||
|
check(seen.includes('c2-d1') && !seen.includes('c2-d1v'), '落点 a_safe:变体 A 节点可见、变体 B 节点被跳过');
|
||||||
|
check(!seen.includes('c3-d1a'), '未选 c_scout:flag_fresh_collapse 差分节点被跳过(无死路,继续走到 c3-d1b)');
|
||||||
|
check(seen.includes('c3-d1b'), '跳过差分节点后落到主线下一节点');
|
||||||
|
check(!seen.includes('c3-d6c'), '动机非 d_trust:§3-7 追加句节点被跳过');
|
||||||
|
const d5a = chapters[1].nodes.find((n) => n.id === 'c2-d5a');
|
||||||
|
check(
|
||||||
|
d5a?.type === 'dialogue' && !eng.renderText(d5a.text).includes('通讯频道'),
|
||||||
|
'wenjian_health=recovering:温简在场,「通讯频道」内联段不渲染',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10c. 差分路径:c_scout 塌梁 + b_exposure 温简虚弱 + C3-02 沉默退出
|
||||||
|
{
|
||||||
|
const s = new MemoryStore();
|
||||||
|
const eng = new StoryEngine(chapters, s, new MockJevClient());
|
||||||
|
const { seen, rendered, finished } = await walkAll(eng, { 'C2-03': 'c_scout', 'C2-01': 'b_exposure', C3_02_SILENT: '1' });
|
||||||
|
check(finished, '条件系统下差分路径三章跑通');
|
||||||
|
check(seen.includes('c3-d1a'), '选了 c_scout:flag_fresh_collapse 差分节点可见');
|
||||||
|
check(eng.state.flags.flag_fresh_collapse === 'true' && eng.state.flags.wenjian_health === 'weak', '引擎 flags 随选择增量更新');
|
||||||
|
const d5a = chapters[1].nodes.find((n) => n.id === 'c2-d5a');
|
||||||
|
check(
|
||||||
|
d5a?.type === 'dialogue' && eng.renderText(d5a.text).includes('通讯频道'),
|
||||||
|
'wenjian_health=weak:温简台词的「通讯频道」内联段渲染',
|
||||||
|
);
|
||||||
|
const cut = chapters[2].nodes.find((n) => n.id === 'c3-cutscene');
|
||||||
|
check(
|
||||||
|
cut?.type === 'cutscene' && eng.renderText(cut.caption).includes('不出来的人'),
|
||||||
|
'C3-02 沉默退出:cutscene 追加馆长退出台词(exit_room 内联段)',
|
||||||
|
);
|
||||||
|
check(rendered.every((t) => !t.includes('【若')), '渲染输出零【若】标记泄漏(差分路径)');
|
||||||
|
}
|
||||||
|
|
||||||
if (failures > 0) {
|
if (failures > 0) {
|
||||||
console.error(`\n${failures} 项校验失败`);
|
console.error(`\n${failures} 项校验失败`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log('\n全部校验通过');
|
console.log('\n全部校验通过');
|
||||||
+41
@@ -331,3 +331,44 @@ button.secondary {
|
|||||||
color: #e8dcc8;
|
color: #e8dcc8;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- 同意门(ADR-0004) ---- */
|
||||||
|
.consent-box {
|
||||||
|
background: rgba(20, 16, 30, 0.85);
|
||||||
|
border: 1px solid #3a3248;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 48px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-box h2 {
|
||||||
|
color: #f0e6d2;
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 8px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-list {
|
||||||
|
color: #d8ccb8;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.8;
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-list li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-note {
|
||||||
|
color: #d8ccb8;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-box .primary,
|
||||||
|
.consent-box .secondary {
|
||||||
|
margin-right: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|||||||
+23
-1
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { StoryEngine } from './engine/engine';
|
import { StoryEngine } from './engine/engine';
|
||||||
import { LocalStorageEventStore } from './engine/eventStore';
|
import { LocalStorageEventStore } from './engine/eventStore';
|
||||||
@@ -6,6 +6,7 @@ import { SqliteEventStore, isTauriRuntime } from './engine/sqliteEventStore';
|
|||||||
import type { Chapter } from './engine/types';
|
import type { Chapter } from './engine/types';
|
||||||
import { getJevClient } from './services/jev';
|
import { getJevClient } from './services/jev';
|
||||||
import { StoryPlayer } from './ui/StoryPlayer';
|
import { StoryPlayer } from './ui/StoryPlayer';
|
||||||
|
import { ConsentGate } from './ui/ConsentGate';
|
||||||
import { ReportPage } from './ui/report/ReportPage';
|
import { ReportPage } from './ui/report/ReportPage';
|
||||||
import chapter1 from '../content/chapters/chapter1.json';
|
import chapter1 from '../content/chapters/chapter1.json';
|
||||||
import chapter2 from '../content/chapters/chapter2.json';
|
import chapter2 from '../content/chapters/chapter2.json';
|
||||||
@@ -15,6 +16,8 @@ const chapters = [chapter1, chapter2, chapter3] as Chapter[];
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [screen, setScreen] = useState<'story' | 'report'>('story');
|
const [screen, setScreen] = useState<'story' | 'report'>('story');
|
||||||
|
// consent: unknown=查询中 / needed=待同意 / granted=已同意(事件存证命中,同意门只出现一次)
|
||||||
|
const [consent, setConsent] = useState<'unknown' | 'needed' | 'granted'>('unknown');
|
||||||
// 引擎与存储整个会话只建一次;重玩走 engine.replay()(新 sessionId,旧事件保留)
|
// 引擎与存储整个会话只建一次;重玩走 engine.replay()(新 sessionId,旧事件保留)
|
||||||
// 运行时选择:Tauri 原生端用 SQLite,浏览器 dev 用 localStorage(同一 append-only 接口)
|
// 运行时选择:Tauri 原生端用 SQLite,浏览器 dev 用 localStorage(同一 append-only 接口)
|
||||||
const { engine, store, jev } = useMemo(() => {
|
const { engine, store, jev } = useMemo(() => {
|
||||||
@@ -23,6 +26,25 @@ export default function App() {
|
|||||||
return { engine: new StoryEngine(chapters, store, jev), store, jev };
|
return { engine: new StoryEngine(chapters, store, jev), store, jev };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void engine.hasConsent().then((ok) => setConsent(ok ? 'granted' : 'needed'));
|
||||||
|
}, [engine]);
|
||||||
|
|
||||||
|
if (consent !== 'granted') {
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
{consent === 'needed' && (
|
||||||
|
<ConsentGate
|
||||||
|
onAgree={async () => {
|
||||||
|
await engine.grantConsent();
|
||||||
|
setConsent('granted');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
{screen === 'story' ? (
|
{screen === 'story' ? (
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { FOLLOW_UP_DIMENSIONS, FOLLOW_UP_PRIORITY } from './types';
|
|||||||
import type { EventStore, StoryEvent } from './eventStore';
|
import type { EventStore, StoryEvent } from './eventStore';
|
||||||
import { makeEvent } from './eventStore';
|
import { makeEvent } from './eventStore';
|
||||||
import type { JevClient, JevQuestion } from '../services/jev';
|
import type { JevClient, JevQuestion } from '../services/jev';
|
||||||
|
import type { Flags } from './flags';
|
||||||
|
import { choiceFlags, narrationFlags, resolveConditionalText } from './flags';
|
||||||
|
|
||||||
|
/** 告知文案版本:文案变动时升版,consent 事件存证所同意的版本(ADR-0004) */
|
||||||
|
export const CONSENT_VERSION = 'act1-consent-v1';
|
||||||
|
|
||||||
export interface EngineState {
|
export interface EngineState {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -12,6 +17,8 @@ export interface EngineState {
|
|||||||
activeVariant: SceneVariant | null;
|
activeVariant: SceneVariant | null;
|
||||||
/** 追问补全:讲述后检测到缺失维度时挂起,等用户回答或"记不清了" */
|
/** 追问补全:讲述后检测到缺失维度时挂起,等用户回答或"记不清了" */
|
||||||
pendingFollowUp: PendingFollowUp | null;
|
pendingFollowUp: PendingFollowUp | null;
|
||||||
|
/** 分支变量(附录 A):从本轨迹的选择/讲述事件增量推导,驱动条件文本 */
|
||||||
|
flags: Flags;
|
||||||
/** 全部三章完成后为 true → 进入画像报告 */
|
/** 全部三章完成后为 true → 进入画像报告 */
|
||||||
finished: boolean;
|
finished: boolean;
|
||||||
}
|
}
|
||||||
@@ -78,8 +85,10 @@ export class StoryEngine {
|
|||||||
nodeId: start.startNodeId,
|
nodeId: start.startNodeId,
|
||||||
activeVariant: null,
|
activeVariant: null,
|
||||||
pendingFollowUp: null,
|
pendingFollowUp: null,
|
||||||
|
flags: {},
|
||||||
finished: false,
|
finished: false,
|
||||||
};
|
};
|
||||||
|
this.skipHiddenNodes();
|
||||||
}
|
}
|
||||||
|
|
||||||
currentChapter(): Chapter {
|
currentChapter(): Chapter {
|
||||||
@@ -110,6 +119,29 @@ export class StoryEngine {
|
|||||||
} else {
|
} else {
|
||||||
throw new Error(`advance() 不适用于 ${node.type} 节点`);
|
throw new Error(`advance() 不适用于 ${node.type} 节点`);
|
||||||
}
|
}
|
||||||
|
this.skipHiddenNodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 条件节点跳过:节点级【若 …】条件不满足的 dialogue / cutscene 对玩家不可见,
|
||||||
|
* 直接走到 next(条件分支不产生死路)。每次状态迁移后调用。
|
||||||
|
*/
|
||||||
|
private skipHiddenNodes(): void {
|
||||||
|
for (let guard = 0; guard < 100; guard++) {
|
||||||
|
const node = this.currentNode();
|
||||||
|
if (node.type !== 'dialogue' && node.type !== 'cutscene') return;
|
||||||
|
const text = node.type === 'dialogue' ? node.text : node.caption;
|
||||||
|
if (resolveConditionalText(text, this.state.flags) !== null) return;
|
||||||
|
this.state.nodeId = node.next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染文本:求值文案中的全部【若 …】条件标记(节点级 + 内联段),
|
||||||
|
* 返回玩家可见文本。条件标记本身永不渲染给玩家。
|
||||||
|
*/
|
||||||
|
renderText(text: string): string {
|
||||||
|
return resolveConditionalText(text, this.state.flags) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** B 类选择:落事件并按选项跳转 */
|
/** B 类选择:落事件并按选项跳转 */
|
||||||
@@ -129,7 +161,9 @@ export class StoryEngine {
|
|||||||
optionLabel: option.label,
|
optionLabel: option.label,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
Object.assign(this.state.flags, choiceFlags(node.pointId, option.id));
|
||||||
this.state.nodeId = option.next;
|
this.state.nodeId = option.next;
|
||||||
|
this.skipHiddenNodes();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,8 +269,10 @@ export class StoryEngine {
|
|||||||
transcript,
|
transcript,
|
||||||
});
|
});
|
||||||
await this.store.append(event);
|
await this.store.append(event);
|
||||||
|
Object.assign(this.state.flags, narrationFlags(node.pointId, variant.variantId, transcript));
|
||||||
this.state.activeVariant = variant;
|
this.state.activeVariant = variant;
|
||||||
this.state.nodeId = node.next;
|
this.state.nodeId = node.next;
|
||||||
|
this.skipHiddenNodes();
|
||||||
return { variant, event };
|
return { variant, event };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,6 +406,30 @@ export class StoryEngine {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
this.state.nodeId = node.next;
|
this.state.nodeId = node.next;
|
||||||
|
this.skipHiddenNodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同意存证(ADR-0004):告知与同意的 UX 由 ConsentGate 承担,
|
||||||
|
* 同意动作在这里 append 一条 consent 事件(含版本与时间戳)。
|
||||||
|
* 事件存在于任意轨迹即视为已同意——同意门全剧只出现一次。
|
||||||
|
*/
|
||||||
|
async hasConsent(): Promise<boolean> {
|
||||||
|
return (await this.store.list()).some((e) => e.kind === 'consent');
|
||||||
|
}
|
||||||
|
|
||||||
|
async grantConsent(): Promise<void> {
|
||||||
|
if (await this.hasConsent()) return;
|
||||||
|
await this.store.append(
|
||||||
|
makeEvent({
|
||||||
|
sessionId: this.state.sessionId,
|
||||||
|
chapterId: this.state.chapterId,
|
||||||
|
kind: 'consent',
|
||||||
|
pointId: 'CONSENT',
|
||||||
|
dimensions: [],
|
||||||
|
consentVersion: CONSENT_VERSION,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -384,6 +444,8 @@ export class StoryEngine {
|
|||||||
this.state.nodeId = chapter.startNodeId;
|
this.state.nodeId = chapter.startNodeId;
|
||||||
this.state.activeVariant = null;
|
this.state.activeVariant = null;
|
||||||
this.state.pendingFollowUp = null;
|
this.state.pendingFollowUp = null;
|
||||||
|
this.state.flags = {};
|
||||||
this.state.finished = false;
|
this.state.finished = false;
|
||||||
|
this.skipHiddenNodes();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ export interface StoryEvent {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
ts: number;
|
ts: number;
|
||||||
chapterId: string;
|
chapterId: string;
|
||||||
kind: 'choice' | 'narration' | 'profile' | 'followup' | 'followup_skipped';
|
kind: 'choice' | 'narration' | 'profile' | 'followup' | 'followup_skipped' | 'consent';
|
||||||
/** 选择点 ID,如 C1-03 */
|
/** 选择点 ID,如 C1-03;consent 事件固定为 'CONSENT' */
|
||||||
pointId: string;
|
pointId: string;
|
||||||
dimensions: DimensionTag[];
|
dimensions: DimensionTag[];
|
||||||
|
/** consent 事件:同意的告知文案版本(ADR-0004 存证) */
|
||||||
|
consentVersion?: string;
|
||||||
/** choice 事件:所选选项 */
|
/** choice 事件:所选选项 */
|
||||||
optionId?: string;
|
optionId?: string;
|
||||||
optionLabel?: string;
|
optionLabel?: string;
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { StoryEvent } from './eventStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分支变量(flag)系统 —— 依据 docs/script/act1.md 附录 A。
|
||||||
|
*
|
||||||
|
* 引擎不单独存变量:全部变量从事件流推导(append-only 的唯一事实源)。
|
||||||
|
* 本模块提供:
|
||||||
|
* 1. choiceFlags / narrationFlags:单个事件 → 变量赋值增量;
|
||||||
|
* 2. deriveFlags:整条事件流 → 变量全集(verify 与画像用);
|
||||||
|
* 3. 条件文本解析:章节 JSON 里的【若 变量 = 值】标记按当前 flag 求值,
|
||||||
|
* 不满足的文本对玩家不可见(marker 本身永不渲染)。
|
||||||
|
*
|
||||||
|
* 条件语法(JSON 文案内):
|
||||||
|
* - 节点级(整条文本的条件):`【若 变量 = 值】正文……` —— 不满足则整点跳过(走 next);
|
||||||
|
* - 内联段:`【若 变量 = 值1 / 值2:内容】` —— 满足则替换为内容,不满足则整段移除;
|
||||||
|
* - 取反:`!=`(如 `【若 c2_06_jade != a_public:……】`)。
|
||||||
|
*/
|
||||||
|
export type Flags = Record<string, string>;
|
||||||
|
|
||||||
|
/** 解谜点正确答案(optionId),用于 *_solved 变量 */
|
||||||
|
const PUZZLE_ANSWERS: Record<string, string> = {
|
||||||
|
'C1-05': 'stele_guardian',
|
||||||
|
'C2-04': 'heart_xin',
|
||||||
|
'C3-01': 'rite_correct',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 选择事件 → 变量赋值(变量名与取值对齐附录 A;optionId 已与枚举值对齐) */
|
||||||
|
export function choiceFlags(pointId: string, optionId: string): Flags {
|
||||||
|
const f: Flags = {};
|
||||||
|
switch (pointId) {
|
||||||
|
case 'C1-03':
|
||||||
|
f.c1_03_motive = optionId; // a_truth / b_thrill / c_escape / d_trust
|
||||||
|
break;
|
||||||
|
case 'C1-04':
|
||||||
|
f.c1_04_response = optionId;
|
||||||
|
if (optionId === 'd_play_along') f.flag_laogui_thread = 'true';
|
||||||
|
break;
|
||||||
|
case 'C1-05':
|
||||||
|
case 'C2-04':
|
||||||
|
case 'C3-01': {
|
||||||
|
const prefix = pointId.toLowerCase().replace('-', '_');
|
||||||
|
f[`${prefix}_solved`] = String(optionId === PUZZLE_ANSWERS[pointId]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'C1-06':
|
||||||
|
f.c1_06_landing = optionId; // a_safe / b_precise
|
||||||
|
break;
|
||||||
|
case 'C2-01':
|
||||||
|
f.c2_01_medicine = optionId;
|
||||||
|
f.wenjian_health = optionId === 'a_weakest' || optionId === 'd_process' ? 'recovering' : 'weak';
|
||||||
|
break;
|
||||||
|
case 'C2-03':
|
||||||
|
f.c2_03_route = optionId; // a_main / b_alley / c_scout
|
||||||
|
if (optionId === 'c_scout') f.flag_fresh_collapse = 'true';
|
||||||
|
break;
|
||||||
|
case 'C2-06':
|
||||||
|
f.c2_06_jade = optionId; // a_public / b_private / c_watch
|
||||||
|
if (optionId === 'b_private') f.flag_laogui_hint = 'true';
|
||||||
|
if (optionId === 'c_watch') f.flag_watched_return = 'true';
|
||||||
|
break;
|
||||||
|
case 'C3-03':
|
||||||
|
f.c3_03_choice = optionId;
|
||||||
|
f.atang_injury = optionId === 'a_person' ? 'bruise' : optionId === 'b_relic' ? 'fracture' : 'sprain';
|
||||||
|
f.wenjian_injury = optionId === 'c_command' ? 'shoulder' : 'none';
|
||||||
|
f.relic_loss = optionId === 'a_person' ? 'one_ding' : 'none';
|
||||||
|
break;
|
||||||
|
case 'C3-04':
|
||||||
|
f.c3_04_stance = optionId;
|
||||||
|
f.flag_laogui_reported = String(optionId !== 'b_plead');
|
||||||
|
if (optionId === 'b_plead') f.flag_shenduo_cover = 'true';
|
||||||
|
break;
|
||||||
|
case 'C3-05':
|
||||||
|
f.c3_05_rope = optionId;
|
||||||
|
f.flag_rope_holder = optionId === 'a_accept' ? 'you' : optionId === 'b_protocol' ? 'shenduo' : 'atang';
|
||||||
|
break;
|
||||||
|
case 'C3-06':
|
||||||
|
f.flag_arch_doubt = 'true';
|
||||||
|
if (optionId.startsWith('c_confide_')) {
|
||||||
|
f.c3_06_response = 'c_confide';
|
||||||
|
f.c3_06_confidant = optionId.slice('c_confide_'.length); // shenduo / atang / wenjian
|
||||||
|
} else {
|
||||||
|
f.c3_06_response = optionId; // a_confront / b_conceal
|
||||||
|
f.c3_06_confidant = 'null';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** C2-02 / C2-05 场景变体 → 情绪签名枚举(附录 A) */
|
||||||
|
const VARIANT_SIGNATURE: Record<string, string> = {
|
||||||
|
intel_full: 'self_blame',
|
||||||
|
intel_mid: 'at_peace',
|
||||||
|
intel_low: 'outward_anger',
|
||||||
|
intel_silent: 'silent',
|
||||||
|
wenjian_close_early: 'deep',
|
||||||
|
wenjian_close_late: 'shallow',
|
||||||
|
wenjian_distant: 'silent',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 讲述事件 → 变量赋值 */
|
||||||
|
export function narrationFlags(pointId: string, variantId: string | undefined, transcript: string | null): Flags {
|
||||||
|
const f: Flags = {};
|
||||||
|
switch (pointId) {
|
||||||
|
case 'C1-02':
|
||||||
|
if (transcript === null) f.c1_02_signature = 'silent';
|
||||||
|
break;
|
||||||
|
case 'C2-02': {
|
||||||
|
const sig = transcript === null ? 'silent' : variantId ? VARIANT_SIGNATURE[variantId] : undefined;
|
||||||
|
if (sig) f.c2_02_signature = sig;
|
||||||
|
if (variantId === 'intel_full') f.flag_prophecy_heard = 'true';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'C2-05': {
|
||||||
|
const depth = transcript === null ? 'silent' : variantId ? VARIANT_SIGNATURE[variantId] : undefined;
|
||||||
|
if (depth) f.c2_05_depth = depth;
|
||||||
|
if (variantId === 'wenjian_close_early') f.flag_wenjian_secret = 'true';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'C3-02':
|
||||||
|
f.c3_02_mode = transcript === null ? 'exit_room' : 'farewell_done';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 整条事件流 → 变量全集(后发生的事件覆盖先发生的,重玩新轨迹自然刷新) */
|
||||||
|
export function deriveFlags(events: StoryEvent[]): Flags {
|
||||||
|
const flags: Flags = {};
|
||||||
|
for (const e of events) {
|
||||||
|
if (e.kind === 'choice' && e.optionId) Object.assign(flags, choiceFlags(e.pointId, e.optionId));
|
||||||
|
else if (e.kind === 'narration') Object.assign(flags, narrationFlags(e.pointId, e.variantId, e.transcript ?? null));
|
||||||
|
}
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 条件文本解析 ----------
|
||||||
|
|
||||||
|
const CONDITION_RE = /【若([^】]+)】/g;
|
||||||
|
|
||||||
|
/** 求值单个条件表达式,如 `c2_06_jade = a_public`、`wenjian_health != weak` */
|
||||||
|
export function evalCondition(expr: string, flags: Flags): boolean {
|
||||||
|
const m = expr.match(/^\s*([A-Za-z0-9_]+)\s*(!=|=)\s*([^:]+?)\s*$/);
|
||||||
|
if (!m) return false; // 解析失败按不满足处理:宁可少显示,不泄漏标记
|
||||||
|
const [, name, op, rawValues] = m;
|
||||||
|
const values = rawValues.split('/').map((v) => v.trim());
|
||||||
|
const hit = values.includes(flags[name] ?? '');
|
||||||
|
return op === '=' ? hit : !hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点级条件:文本以 `【若 …】`(无内联内容)开头时,拆出条件与正文。
|
||||||
|
* 内联段(含 `:`)不算节点级条件。
|
||||||
|
*/
|
||||||
|
export function splitNodeCondition(text: string): { condition: string | null; body: string } {
|
||||||
|
const m = text.match(/^【若([^】]+)】/);
|
||||||
|
if (!m) return { condition: null, body: text };
|
||||||
|
if (m[1].includes(':')) return { condition: null, body: text };
|
||||||
|
return { condition: m[1], body: text.slice(m[0].length) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染解析:节点级条件不满足 → null(调用方跳过该节点);
|
||||||
|
* 否则剥离节点级标记、求值全部内联段,返回玩家可见文本。
|
||||||
|
*/
|
||||||
|
export function resolveConditionalText(text: string, flags: Flags): string | null {
|
||||||
|
const { condition, body } = splitNodeCondition(text);
|
||||||
|
if (condition !== null && !evalCondition(condition, flags)) return null;
|
||||||
|
return body.replace(CONDITION_RE, (_seg, inner: string) => {
|
||||||
|
const idx = inner.indexOf(':');
|
||||||
|
if (idx < 0) return ''; // 裸条件标记不渲染
|
||||||
|
return evalCondition(inner.slice(0, idx), flags) ? inner.slice(idx + 1) : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { CONSENT_VERSION } from '../engine/engine';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 正式同意门(ADR-0004 合规义务):首次启动、进入剧情前展示一次。
|
||||||
|
* 告知 = 数据用途 + 语音即焚 + 不展示给其他用户 + 历史不可涂改;
|
||||||
|
* 「同意并开始」append consent 事件存证(含 consentVersion),之后全剧不再出现。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NOTICE_ITEMS = [
|
||||||
|
'你在剧情中的选择与语音讲述,将用于为你推荐伴侣。按本心选,没有对错。',
|
||||||
|
'语音只用于转写文字与提取语气特征(语速、停顿等),提取后即焚——不保存音频,不含声纹,不可还原你的声音。',
|
||||||
|
'你的记忆与经历仅作模型输入,永远不会原样展示给其他用户;记忆展厅向匹配对象开放,需你逐次明确授权。',
|
||||||
|
'历史选择不可涂改;想重来可以重玩章节——重玩产生新轨迹,旧轨迹原样保留。',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ConsentGate({ onAgree }: { onAgree: () => Promise<void> }) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [declined, setDeclined] = useState(false);
|
||||||
|
|
||||||
|
if (declined) {
|
||||||
|
return (
|
||||||
|
<div className="consent-box">
|
||||||
|
<h2>门为你留着</h2>
|
||||||
|
<p className="consent-note">档案馆不催。你准备好了,随时回来。</p>
|
||||||
|
<button className="secondary" onClick={() => setDeclined(false)}>
|
||||||
|
重新考虑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="consent-box">
|
||||||
|
<div className="mechanism-tag">时空档案馆 · 入馆告知(仅一次)</div>
|
||||||
|
<h2>在你点燃第一段燃料之前</h2>
|
||||||
|
<ul className="consent-list">
|
||||||
|
{NOTICE_ITEMS.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true);
|
||||||
|
await onAgree();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
同意并开始
|
||||||
|
</button>
|
||||||
|
<button className="secondary" disabled={busy} onClick={() => setDeclined(true)}>
|
||||||
|
暂不
|
||||||
|
</button>
|
||||||
|
<p className="hint">点击「同意并开始」即表示你已知晓并同意上述内容(告知版本 {CONSENT_VERSION},同意记录仅作合规存证)。</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+10
-8
@@ -60,13 +60,15 @@ export function StoryPlayer({ engine, onFinish }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="speaker-name">{node.speaker}</div>
|
<div className="speaker-name">{node.speaker}</div>
|
||||||
<p className="dialogue-text">{node.text}</p>
|
<p className="dialogue-text">{engine.renderText(node.text)}</p>
|
||||||
<span className="hint">点击继续 ▸</span>
|
<span className="hint">点击继续 ▸</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{node.type === 'choice' && <ChoiceView node={node} onPick={async (id) => { await engine.choose(id); rerender(); }} />}
|
{node.type === 'choice' && (
|
||||||
|
<ChoiceView node={node} renderText={(t) => engine.renderText(t)} onPick={async (id) => { await engine.choose(id); rerender(); }} />
|
||||||
|
)}
|
||||||
|
|
||||||
{node.type === 'narration' && (
|
{node.type === 'narration' && (
|
||||||
<NarrationView
|
<NarrationView
|
||||||
@@ -84,7 +86,7 @@ export function StoryPlayer({ engine, onFinish }: Props) {
|
|||||||
{node.type === 'cutscene' && (
|
{node.type === 'cutscene' && (
|
||||||
<div className="cutscene" onClick={() => { engine.advance(); rerender(); }}>
|
<div className="cutscene" onClick={() => { engine.advance(); rerender(); }}>
|
||||||
<div className="cutscene-placeholder">▶ {node.video}</div>
|
<div className="cutscene-placeholder">▶ {node.video}</div>
|
||||||
<p>{node.caption}</p>
|
<p>{engine.renderText(node.caption)}</p>
|
||||||
<span className="hint">点击继续 ▸</span>
|
<span className="hint">点击继续 ▸</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -108,11 +110,11 @@ export function StoryPlayer({ engine, onFinish }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChoiceView({ node, onPick }: { node: ChoiceNode; onPick: (id: string) => void }) {
|
function ChoiceView({ node, renderText, onPick }: { node: ChoiceNode; renderText: (t: string) => string; onPick: (id: string) => void }) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="choice-box">
|
<div className="choice-box">
|
||||||
<p className="choice-prompt">{node.prompt}</p>
|
<p className="choice-prompt">{renderText(node.prompt)}</p>
|
||||||
<div className="choice-options">
|
<div className="choice-options">
|
||||||
{node.options.map((o) => (
|
{node.options.map((o) => (
|
||||||
<button
|
<button
|
||||||
@@ -125,7 +127,7 @@ function ChoiceView({ node, onPick }: { node: ChoiceNode; onPick: (id: string) =
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{o.label}
|
{renderText(o.label)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +186,7 @@ function NarrationView({
|
|||||||
const IDENTITY_FIELDS: { key: keyof IdentityProfile; label: string; placeholder: string }[] = [
|
const IDENTITY_FIELDS: { key: keyof IdentityProfile; label: string; placeholder: string }[] = [
|
||||||
{ key: 'city', label: '城市', placeholder: '如:杭州' },
|
{ key: 'city', label: '城市', placeholder: '如:杭州' },
|
||||||
{ key: 'age', label: '年龄段', placeholder: '如:28-32' },
|
{ key: 'age', label: '年龄段', placeholder: '如:28-32' },
|
||||||
{ key: 'gender', label: '性别', placeholder: '男 / 女 / 其他' },
|
{ key: 'gender', label: '性别', placeholder: '男 / 女 / 其他 / 不便透露' },
|
||||||
{ key: 'occupation', label: '职业', placeholder: '如:互联网' },
|
{ key: 'occupation', label: '职业', placeholder: '如:互联网' },
|
||||||
{ key: 'education', label: '学历', placeholder: '如:硕士' },
|
{ key: 'education', label: '学历', placeholder: '如:硕士' },
|
||||||
];
|
];
|
||||||
@@ -213,7 +215,7 @@ function IdentityView({ node, engine, onDone }: { node: IdentityNode; engine: St
|
|||||||
return (
|
return (
|
||||||
<div className="identity-box">
|
<div className="identity-box">
|
||||||
<div className="mechanism-tag">身份登记 · 确认</div>
|
<div className="mechanism-tag">身份登记 · 确认</div>
|
||||||
<p className="narration-prompt">档案馆听写的结果如下,不对的地方直接改——这几项只用于帮你找到合适的人。</p>
|
<p className="narration-prompt">档案馆听写的结果如下,不对的地方直接改——这几项仅用于匹配的硬性筛选(城市、年龄段等),不会展示给其他用户。</p>
|
||||||
{IDENTITY_FIELDS.map((f) => (
|
{IDENTITY_FIELDS.map((f) => (
|
||||||
<label key={f.key} className="identity-field">
|
<label key={f.key} className="identity-field">
|
||||||
<span>{f.label}</span>
|
<span>{f.label}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user