From b0c7ef2daf2906bc83fa8d06ddfd8644d052e7ce Mon Sep 17 00:00:00 2001 From: Jayway Liu <14022498+bluejayway@user.noreply.gitee.com> Date: Sat, 3 Oct 2026 18:21:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=A6=96=E5=90=AF=E5=90=8C=E6=84=8F?= =?UTF-8?q?=E9=97=A8=EF=BC=88consent=20=E4=BA=8B=E4=BB=B6=E5=AD=98?= =?UTF-8?q?=E8=AF=81=EF=BC=89+=20=E6=9D=A1=E4=BB=B6=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=EF=BC=88flags=20=E6=8E=A8=E5=AF=BC=2027=20?= =?UTF-8?q?=E5=8F=98=E9=87=8F=EF=BC=89+=2010=20=E7=B1=BB=E5=86=85=E5=AE=B9?= =?UTF-8?q?-=E7=95=8C=E9=9D=A2=E4=B8=8D=E4=B8=80=E8=87=B4=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/chapters/chapter1.json | 4 +- content/chapters/chapter2.json | 22 +++-- content/chapters/chapter3.json | 14 +-- scripts/verify.ts | 107 +++++++++++++++++++- src/App.css | 41 ++++++++ src/App.tsx | 24 ++++- src/engine/engine.ts | 62 ++++++++++++ src/engine/eventStore.ts | 6 +- src/engine/flags.ts | 174 +++++++++++++++++++++++++++++++++ src/ui/ConsentGate.tsx | 58 +++++++++++ src/ui/StoryPlayer.tsx | 18 ++-- 11 files changed, 501 insertions(+), 29 deletions(-) create mode 100644 src/engine/flags.ts create mode 100644 src/ui/ConsentGate.tsx diff --git a/content/chapters/chapter1.json b/content/chapters/chapter1.json index f9898ce..e3ec129 100644 --- a/content/chapters/chapter1.json +++ b/content/chapters/chapter1.json @@ -16,7 +16,7 @@ "id": "c1-open2", "type": "dialogue", "speaker": "旁白", - "text": "显示器右下角,弹出一封没有发件人的邮件。只有一行字:「你想不想听听它自己怎么说?——时空档案馆」。你盯着那行字看了很久。窗外雨声很大,像很多人在很远的地方说话。(点击确认即视为知晓:剧情选择将用于为你推荐伴侣。按本心选,没有对错——此提示全剧只出现这一次。)", + "text": "显示器右下角,弹出一封没有发件人的邮件。只有一行字:「你想不想听听它自己怎么说?——时空档案馆」。你盯着那行字看了很久。窗外雨声很大,像很多人在很远的地方说话。", "sprite": "scene", "next": "c1-d1" }, @@ -278,7 +278,7 @@ "id": "c1-05", "type": "choice", "pointId": "C1-05", - "prompt": "碑额拓片特写:上方是屋顶(宀),屋顶之下一个安坐的人形,膝前有衣袂垂落之态——宀下一个女子安坐的形象。这是什么字?它刻在碑额上,意味着这是什么碑?(可主动求提示,每用一档降一档解谜评分,不设失败:一档拆字、二档给构件含义、三档给答案范围。)", + "prompt": "碑额拓片特写:上方是屋顶(宀),屋顶之下一个安坐的人形,膝前有衣袂垂落之态——宀下一个女子安坐的形象。这是什么字?它刻在碑额上,意味着这是什么碑?", "dimensions": ["culture"], "options": [ { "id": "stele_guardian", "label": "「安」。室中有女,安坐不惊——这是镇城安民碑,碑额朝向即古城安门所在。", "next": "c1-05-ok" }, diff --git a/content/chapters/chapter2.json b/content/chapters/chapter2.json index c2d075a..48bfa5e 100644 --- a/content/chapters/chapter2.json +++ b/content/chapters/chapter2.json @@ -8,7 +8,15 @@ "id": "c2-d1", "type": "dialogue", "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", "next": "c2-d1a" }, @@ -16,7 +24,7 @@ "id": "c2-d1a", "type": "dialogue", "speaker": "旁白", - "text": "(两变体汇合处)小队按预定暗号在城西水门内会合,建立营地。沈铎分派:明日采集开始。", + "text": "小队按预定暗号在城西水门内会合,建立营地。沈铎分派:明日采集开始。", "sprite": "scene", "next": "c2-d2" }, @@ -245,7 +253,7 @@ "options": [ { "id": "a_main", "label": "「走主街。」(支持沈铎:四十分钟买确定性,值)", "next": "c2-03-ra" }, { "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", "type": "dialogue", "speaker": "旁白", - "text": "你先进巷子,中段一根悬梁真的塌了——提前预警,你扑出来,灰头土脸,人没事。全队退回走主街,损失二十分钟。阿棠主动说「是我撺掇的」,沈铎说「探法本身没错,下次用绳」。你获得一条独有观察:塌梁的断口是新茬——这片废墟不久前塌过一次。(flag_fresh_collapse = true,宫墟深处会回收这条观察。)", + "text": "你先进巷子,中段一根悬梁真的塌了——提前预警,你扑出来,灰头土脸,人没事。全队退回走主街,损失二十分钟。阿棠主动说「是我撺掇的」,沈铎说「探法本身没错,下次用绳」。你获得一条独有观察:塌梁的断口是新茬——这片废墟不久前塌过一次。记住这个细节,往下走,你会再想起它。", "sprite": "scene", "next": "c2-d5" }, @@ -300,7 +308,7 @@ "id": "c2-04", "type": "choice", "pointId": "C2-04", - "prompt": "星图盘外圈完整刻着东方苍龙七宿(角、亢、氐、房、心、尾、箕)的连线,被磨去的中央区域正是苍龙的「心脏」。盘面残留一行小字铭文:「火中,而后城安。」补全被磨去的部分——缺的是哪一宿?「大火」又是哪一颗?(可求提示:一档指数数缺哪宿、二档解铭文钥匙、三档给答案。)", + "prompt": "星图盘外圈完整刻着东方苍龙七宿(角、亢、氐、房、心、尾、箕)的连线,被磨去的中央区域正是苍龙的「心脏」。盘面残留一行小字铭文:「火中,而后城安。」补全被磨去的部分——缺的是哪一宿?「大火」又是哪一颗?", "dimensions": ["culture"], "options": [ { "id": "heart_xin", "label": "心宿三星,排成一线微弯;中间最红最亮的那颗是大火——心宿二。把它点在苍龙的心口。", "next": "c2-04-ok" }, @@ -393,7 +401,7 @@ "olfactory_gustatory": "那段日子有什么气味或味道,留在记忆里了吗?" }, "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_distant", "label": "【守夜】你们并肩坐到火熄。温简往你手里塞了个烤热的薯,什么都没说。次日他照旧对你点头,不多一分不少一分——但阿棠偷偷告诉你:「温简昨晚跟沈铎说,你这个人『稳』。」基调:沉默被当作一种讲述接住了;温简的亲密度走慢线。", "tint": "#4b4b5a" } ], @@ -447,7 +455,7 @@ "id": "c2-06-rb", "type": "dialogue", "speaker": "老鬼", - "text": "次日私下对质。老鬼收起笑,盯了你三秒,忽然又笑了:「新人,你比看上去老练。」他把玉佩交给你,让你转交登记,临走撂下一句:「这队里不止我一个人手上不干净,你慢慢看。」(沈铎收到玉佩时问来路,你替他圆了——或者没有:剧本的二级选项,记 c2_06_cover,引擎暂不建模。)", + "text": "次日私下对质。老鬼收起笑,盯了你三秒,忽然又笑了:「新人,你比看上去老练。」他把玉佩交给你,让你转交登记,临走撂下一句:「这队里不止我一个人手上不干净,你慢慢看。」沈铎收到玉佩时问了来路——替他圆,还是不圆,那一刻是你自己拿的主意。", "sprite": "laogui", "next": "c2-d8" }, diff --git a/content/chapters/chapter3.json b/content/chapters/chapter3.json index 1462c6c..494ddf1 100644 --- a/content/chapters/chapter3.json +++ b/content/chapters/chapter3.json @@ -16,7 +16,7 @@ "id": "c3-d1a", "type": "dialogue", "speaker": "旁白", - "text": "【若 flag_fresh_collapse = true(C2-03 你亲自探路)】台阶转角处,你又见到了那种新茬——塌过的夯土,被人重新夯实过。不久之前,有人在这里修过路。往下走。城在等的那一天到来之前,已经有人急着下去了。", + "text": "【若 flag_fresh_collapse = true】台阶转角处,你又见到了那种新茬——塌过的夯土,被人重新夯实过。不久之前,有人在这里修过路。往下走。城在等的那一天到来之前,已经有人急着下去了。", "sprite": "scene", "next": "c3-d1b" }, @@ -48,7 +48,7 @@ "id": "c3-01", "type": "choice", "pointId": "C3-01", - "prompt": "台基上器物分四区:盘与盉(盥洗之器)、爵与尊(酒器)、鼎七件簋六件(盛牺牲黍稷)、最深处一座空着的玉璧座(礼天以璧)。① 还原仪式流程的先后顺序;② 空璧座意味着什么?(可求提示:一档按用途分类、二档给列鼎制度与「先洁后献」、三档给答案。)", + "prompt": "台基上器物分四区:盘与盉(盥洗之器)、爵与尊(酒器)、鼎七件簋六件(盛牺牲黍稷)、最深处一座空着的玉璧座(礼天以璧)。还原仪式流程的先后顺序,并回答:空璧座意味着什么?", "dimensions": ["culture"], "options": [ { "id": "rite_correct", "label": "沃盥 → 献酒 → 荐食 → 奉璧。七鼎六簋,主祭是诸侯级的国君——这是国祭;璧座是空的:献给天的那件礼,始终没拿出来。或者,拿不出来。", "next": "c3-01-ok" }, @@ -161,7 +161,7 @@ "id": "c3-cutscene", "type": "cutscene", "video": "cutscene-illusion-clear", - "caption": "黑石的画面缓缓沉下去,像深水重新凝住。石面最后亮了一下——映出的是你自己现在的脸。然后石室另一侧,一扇先前不存在的石门,无声滑开。馆长:「门开了。它不要你的记忆,它要的是你承认那是你的。这段不登记入档。问心之室里的事,归你。」【若你选择退出石室,石门同样滑开,馆长台词换为:「不出来的人,档案馆不催。你来了,看了,走了——这也是告别的一种。」】", + "caption": "黑石的画面缓缓沉下去,像深水重新凝住。石面最后亮了一下——映出的是你自己现在的脸。然后石室另一侧,一扇先前不存在的石门,无声滑开。馆长:「门开了。它不要你的记忆,它要的是你承认那是你的。这段不登记入档。问心之室里的事,归你。」【若 c3_02_mode = exit_room:你最终没有走进去。石门同样滑开。馆长:「不出来的人,档案馆不催。你来了,看了,走了——这也是告别的一种。」】", "next": "c3-d3" }, { @@ -244,7 +244,7 @@ "id": "c3-d4b", "type": "dialogue", "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", "next": "c3-d4c" }, @@ -388,7 +388,7 @@ "id": "c3-d6c", "type": "dialogue", "speaker": "旁白", - "text": "【若 c1_03_motive = d_trust(你的动机是「有人希望我去」)】通话末尾还有一句,声音更低,几乎不像在通话,像在自言自语:「……是的,托付那类,最不好办。」", + "text": "【若 c1_03_motive = d_trust】通话末尾还有一句,声音更低,几乎不像在通话,像在自言自语:「……是的,托付那类,最不好办。」", "sprite": "scene", "next": "c3-d6d" }, @@ -404,14 +404,14 @@ "id": "c3-06", "type": "choice", "pointId": "C3-06", - "prompt": "档案馆的隐瞒,你打算怎么办?(剧本的二级选项「叫醒谁」并入本点:选哪个队友,即向谁倾诉。)", + "prompt": "档案馆的隐瞒,你打算怎么办?(叫醒队友的话——叫醒谁,就向谁倾诉。)", "dimensions": ["attachment", "mate_preference"], "options": [ { "id": "a_confront", "label": "(按下通讯键,直接问馆长)「刚才那段通话。问心石是你们放的?璧是什么?——现在,跟我说实话。」", "next": "c3-06-ra" }, { "id": "b_conceal", "label": "(退回营地,躺平,盯着黑暗)按下不表。听到的每一个字都记住。在弄清楚「他们为什么要瞒」之前,装作没听过。", "next": "c3-06-rb" }, { "id": "c_confide_shenduo", "label": "(叫醒沈铎,把听到的一切说出来)", "next": "c3-06-rc1" }, { "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" } ] }, { diff --git a/scripts/verify.ts b/scripts/verify.ts index dc7f28e..1ad5496 100644 --- a/scripts/verify.ts +++ b/scripts/verify.ts @@ -6,12 +6,15 @@ * 4. 追问补全流程:缺失检测 → 追问事件 / 跳过事件("记不清了"合法)→ 回答合并进讲述文本; * 5. EventStore 各实现(含 SqliteEventStore)的 append-only 性质(接口层面断言无 update/delete; * SQLite 实连只能在 Tauri 运行时内进行,Node 下不实例化)。 + * 6. 同意门(ADR-0004):consent 事件存证、幂等、跨会话跳过; + * 7. 条件文本:flag 推导(附录 A)、【若】标记零泄漏、差分显隐、条件不满足节点跳过无死路。 * 运行:pnpm verify */ 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 type { EventStore, StoryEvent } from '../src/engine/eventStore'; +import { choiceFlags, narrationFlags } from '../src/engine/flags'; import { SqliteEventStore } from '../src/engine/sqliteEventStore'; import type { Chapter, ChoiceNode, NarrationNode } 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 = {}) { + 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) { console.error(`\n${failures} 项校验失败`); process.exit(1); } -console.log('\n全部校验通过'); +console.log('\n全部校验通过'); \ No newline at end of file diff --git a/src/App.css b/src/App.css index 0379158..f01d27e 100644 --- a/src/App.css +++ b/src/App.css @@ -331,3 +331,44 @@ button.secondary { color: #e8dcc8; 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; +} diff --git a/src/App.tsx b/src/App.tsx index c168ea5..ea81935 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import './App.css'; import { StoryEngine } from './engine/engine'; import { LocalStorageEventStore } from './engine/eventStore'; @@ -6,6 +6,7 @@ import { SqliteEventStore, isTauriRuntime } from './engine/sqliteEventStore'; import type { Chapter } from './engine/types'; import { getJevClient } from './services/jev'; import { StoryPlayer } from './ui/StoryPlayer'; +import { ConsentGate } from './ui/ConsentGate'; import { ReportPage } from './ui/report/ReportPage'; import chapter1 from '../content/chapters/chapter1.json'; import chapter2 from '../content/chapters/chapter2.json'; @@ -15,6 +16,8 @@ const chapters = [chapter1, chapter2, chapter3] as Chapter[]; export default function App() { const [screen, setScreen] = useState<'story' | 'report'>('story'); + // consent: unknown=查询中 / needed=待同意 / granted=已同意(事件存证命中,同意门只出现一次) + const [consent, setConsent] = useState<'unknown' | 'needed' | 'granted'>('unknown'); // 引擎与存储整个会话只建一次;重玩走 engine.replay()(新 sessionId,旧事件保留) // 运行时选择:Tauri 原生端用 SQLite,浏览器 dev 用 localStorage(同一 append-only 接口) const { engine, store, jev } = useMemo(() => { @@ -23,6 +26,25 @@ export default function App() { return { engine: new StoryEngine(chapters, store, jev), store, jev }; }, []); + useEffect(() => { + void engine.hasConsent().then((ok) => setConsent(ok ? 'granted' : 'needed')); + }, [engine]); + + if (consent !== 'granted') { + return ( +
+ {consent === 'needed' && ( + { + await engine.grantConsent(); + setConsent('granted'); + }} + /> + )} +
+ ); + } + return (
{screen === 'story' ? ( diff --git a/src/engine/engine.ts b/src/engine/engine.ts index d7405f6..0e19b2d 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -3,6 +3,11 @@ import { FOLLOW_UP_DIMENSIONS, FOLLOW_UP_PRIORITY } from './types'; import type { EventStore, StoryEvent } from './eventStore'; import { makeEvent } from './eventStore'; 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 { sessionId: string; @@ -12,6 +17,8 @@ export interface EngineState { activeVariant: SceneVariant | null; /** 追问补全:讲述后检测到缺失维度时挂起,等用户回答或"记不清了" */ pendingFollowUp: PendingFollowUp | null; + /** 分支变量(附录 A):从本轨迹的选择/讲述事件增量推导,驱动条件文本 */ + flags: Flags; /** 全部三章完成后为 true → 进入画像报告 */ finished: boolean; } @@ -78,8 +85,10 @@ export class StoryEngine { nodeId: start.startNodeId, activeVariant: null, pendingFollowUp: null, + flags: {}, finished: false, }; + this.skipHiddenNodes(); } currentChapter(): Chapter { @@ -110,6 +119,29 @@ export class StoryEngine { } else { 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 类选择:落事件并按选项跳转 */ @@ -129,7 +161,9 @@ export class StoryEngine { optionLabel: option.label, }), ); + Object.assign(this.state.flags, choiceFlags(node.pointId, option.id)); this.state.nodeId = option.next; + this.skipHiddenNodes(); } /** @@ -235,8 +269,10 @@ export class StoryEngine { transcript, }); await this.store.append(event); + Object.assign(this.state.flags, narrationFlags(node.pointId, variant.variantId, transcript)); this.state.activeVariant = variant; this.state.nodeId = node.next; + this.skipHiddenNodes(); return { variant, event }; } @@ -370,6 +406,30 @@ export class StoryEngine { }), ); this.state.nodeId = node.next; + this.skipHiddenNodes(); + } + + /** + * 同意存证(ADR-0004):告知与同意的 UX 由 ConsentGate 承担, + * 同意动作在这里 append 一条 consent 事件(含版本与时间戳)。 + * 事件存在于任意轨迹即视为已同意——同意门全剧只出现一次。 + */ + async hasConsent(): Promise { + return (await this.store.list()).some((e) => e.kind === 'consent'); + } + + async grantConsent(): Promise { + 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.activeVariant = null; this.state.pendingFollowUp = null; + this.state.flags = {}; this.state.finished = false; + this.skipHiddenNodes(); } } diff --git a/src/engine/eventStore.ts b/src/engine/eventStore.ts index 4630c41..5c50f03 100644 --- a/src/engine/eventStore.ts +++ b/src/engine/eventStore.ts @@ -17,10 +17,12 @@ export interface StoryEvent { sessionId: string; ts: number; chapterId: string; - kind: 'choice' | 'narration' | 'profile' | 'followup' | 'followup_skipped'; - /** 选择点 ID,如 C1-03 */ + kind: 'choice' | 'narration' | 'profile' | 'followup' | 'followup_skipped' | 'consent'; + /** 选择点 ID,如 C1-03;consent 事件固定为 'CONSENT' */ pointId: string; dimensions: DimensionTag[]; + /** consent 事件:同意的告知文案版本(ADR-0004 存证) */ + consentVersion?: string; /** choice 事件:所选选项 */ optionId?: string; optionLabel?: string; diff --git a/src/engine/flags.ts b/src/engine/flags.ts new file mode 100644 index 0000000..fc152f9 --- /dev/null +++ b/src/engine/flags.ts @@ -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; + +/** 解谜点正确答案(optionId),用于 *_solved 变量 */ +const PUZZLE_ANSWERS: Record = { + '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 = { + 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) : ''; + }); +} diff --git a/src/ui/ConsentGate.tsx b/src/ui/ConsentGate.tsx new file mode 100644 index 0000000..c7c45ad --- /dev/null +++ b/src/ui/ConsentGate.tsx @@ -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 }) { + const [busy, setBusy] = useState(false); + const [declined, setDeclined] = useState(false); + + if (declined) { + return ( +
+

门为你留着

+

档案馆不催。你准备好了,随时回来。

+ +
+ ); + } + + return ( +
+
时空档案馆 · 入馆告知(仅一次)
+

在你点燃第一段燃料之前

+
    + {NOTICE_ITEMS.map((item) => ( +
  • {item}
  • + ))} +
+ + +

点击「同意并开始」即表示你已知晓并同意上述内容(告知版本 {CONSENT_VERSION},同意记录仅作合规存证)。

+
+ ); +} diff --git a/src/ui/StoryPlayer.tsx b/src/ui/StoryPlayer.tsx index 2aa67ae..868e307 100644 --- a/src/ui/StoryPlayer.tsx +++ b/src/ui/StoryPlayer.tsx @@ -60,13 +60,15 @@ export function StoryPlayer({ engine, onFinish }: Props) {
{node.speaker}
-

{node.text}

+

{engine.renderText(node.text)}

点击继续 ▸
)} - {node.type === 'choice' && { await engine.choose(id); rerender(); }} />} + {node.type === 'choice' && ( + engine.renderText(t)} onPick={async (id) => { await engine.choose(id); rerender(); }} /> + )} {node.type === 'narration' && ( { engine.advance(); rerender(); }}>
▶ {node.video}
-

{node.caption}

+

{engine.renderText(node.caption)}

点击继续 ▸ )} @@ -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); return (
-

{node.prompt}

+

{renderText(node.prompt)}

{node.options.map((o) => ( ))}
@@ -184,7 +186,7 @@ function NarrationView({ const IDENTITY_FIELDS: { key: keyof IdentityProfile; label: string; placeholder: string }[] = [ { key: 'city', label: '城市', placeholder: '如:杭州' }, { key: 'age', label: '年龄段', placeholder: '如:28-32' }, - { key: 'gender', label: '性别', placeholder: '男 / 女 / 其他' }, + { key: 'gender', label: '性别', placeholder: '男 / 女 / 其他 / 不便透露' }, { key: 'occupation', label: '职业', placeholder: '如:互联网' }, { key: 'education', label: '学历', placeholder: '如:硕士' }, ]; @@ -213,7 +215,7 @@ function IdentityView({ node, engine, onDone }: { node: IdentityNode; engine: St return (
身份登记 · 确认
-

档案馆听写的结果如下,不对的地方直接改——这几项只用于帮你找到合适的人。

+

档案馆听写的结果如下,不对的地方直接改——这几项仅用于匹配的硬性筛选(城市、年龄段等),不会展示给其他用户。

{IDENTITY_FIELDS.map((f) => (