feat: MVP 工程脚手架——剧情引擎、Act 1 三章内容、Jev 服务层、画像报告与分享卡、Tauri 壳

This commit is contained in:
Jayway Liu
2026-10-03 10:05:10 +08:00
parent f57eabe12c
commit bf9d7dad5b
54 changed files with 8040 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# TypeSafe AI System One (Jev) API key。
# 复制本文件为 .env 并填入真实 key;缺失时前端自动降级为 MockJevClient(确定性伪随机返分)。
VITE_TYPESAFE_API_KEY=
+29
View File
@@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Secrets — 绝不能入库
.env
.env.*
!.env.example
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+70
View File
@@ -0,0 +1,70 @@
# 苍梧疑冢 · 互动剧情测评(MVP)
陌生人交友匹配 app 的冷启动载体:单人互动叙事("影游")。MVP 范围 = 影游第一幕(3 章)
+ 人格画像报告 + 勋章分享卡,**不含匹配和聊天**。
产品共识见 `CONTEXT.md`,架构决策见 `docs/adr/0001`~`0004`,剧本母本见
`docs/story-framework.md`(第 4 节是 Act 1 全部选择点地图)。
## 怎么跑
```bash
pnpm install
pnpm dev # 浏览器开发(无 API key 时自动降级 Mock,见下)
pnpm build # 类型检查 + 产物构建
pnpm verify # 无头校验:选择点地图对照母本 + 引擎三章流程 + 重玩语义
pnpm lint # oxlint
pnpm tauri dev # Tauri 桌面壳(Rust 工具链需先 source ~/.cargo/env)
```
## Jev(TypeSafe AI System One)配置
复制 `.env.example` 为 `.env`,填入 key:
```
VITE_TYPESAFE_API_KEY=<your key>
```
- 有 key:前端直连 `POST https://api.typesafe.ai/v1/systemone`(`HttpJevClient`)。
- 无 key:自动降级 `MockJevClient`(确定性伪随机返分,console 有提示),开发联调不受影响。
- `.env` 已在 `.gitignore` 中,绝不能入库。
## 项目结构
```
content/chapters/ Act 1 三章剧情数据(JSON,选择点 ID/维度标签对母本第 4 节)
src/engine/ 数据驱动剧情引擎(纯 TS,与 UI 解耦)
types.ts 章节 schema:dialogue / choice / narration / cutscene / chapterEnd
engine.ts StoryEngine 状态机:节点推进、分支、情绪签名→场景变体、重玩
eventStore.ts EventStore 接口(append-only,只有 append/list)+ localStorage 实现
src/services/jev.ts JevClient 接口 + HttpJevClient / MockJevClient
src/ui/ 最简影游界面(对话框/选项/按住说话占位/场景变体渐变)
src/ui/report/ 画像报告(Jev 7 维 score)、勋章映射、canvas 分享卡
scripts/verify.ts 内容完整性与流程校验(pnpm verify)
src-tauri/ Tauri 2 壳(iOS/Android/桌面共用)
```
## 架构要点
- **append-only 事件存储(ADR-0004)**:`EventStore` 类型层面只有 `append`/`list`,
没有 update/delete 路径。重玩 = 新 sessionId 的新轨迹,旧事件原样保留。
- **三通道边界(ADR-0003)**:主线全部预写在 JSON 里;Jev 在讲述点只做
"情绪签名 → 预写场景变体" 的选择题(通道 1);C3-02 问心幻境通过
`variantFrom: "C1-02"` 复用第一章的记忆解析。
- **画像报告**:Act 1 结束后把全部事件轨迹汇总为 state,一次 Jev 调用并行问
7 个维度(依恋/冲突风格/价值观/情绪稳定性/风险偏好/文化底蕴/择偶偏好)的
score 问题(criteria 1-10 级),勋章取置信度最高的维度。
## 已知缺口
- **CORS**:浏览器直连 `api.typesafe.ai` 可能有跨域限制,正式环境需加后端代理转发。
- **ASR 未接**:语音讲述点只有"按住说话"占位按钮,松开即以占位转写文本落事件。
- **Tauri SQLite 未实现**:`EventStore` 目前只有 localStorage 实现;原生端应用
tauri-plugin-sql 实现同一接口(表只建 INSERT/SELECT,不建 UPDATE/DELETE 路径),
预留位置见 `src/engine/eventStore.ts` 末尾注释。
- **Tauri 移动端(iOS/Android)推送与构建未验证**:目前只验证了 Rust 壳
`cargo check` 与 `tauri info`,未跑 `tauri android/ios build`。
- **追问补全未接**:讲述点 schema 里有 `followUp` 策略占位(Jev 缺失维度检测 +
NPC 口吻追问,至多 1-2 句),MVP 未实现。
- **美术全部占位**:CSS 色块 + 渐变,无图片/视频素材;cutscene 节点是占位渲染。
- **自适应支线路由未实现**:story-framework 第 6 节的低置信度维度插支线属于后续章节工作。
+154
View File
@@ -0,0 +1,154 @@
{
"id": "chapter1",
"act": 1,
"title": "第 1 章 · 登记",
"startNodeId": "c1-d1",
"nodes": [
{
"id": "c1-d1",
"type": "dialogue",
"speaker": "馆长",
"text": "欢迎来到时空档案馆。登记前按例告知一次:你在剧情中的选择将用于为你推荐伴侣。按本心选,没有对错——这句话我只说一遍。",
"sprite": "curator",
"next": "c1-d2"
},
{
"id": "c1-d2",
"type": "dialogue",
"speaker": "馆长",
"text": "苍梧国,消亡于千年前,只余一座未被盗扰的城墟。你是省考古所研究苍梧的研究员——也是我们选中的口述历史采集员。先登记你的穿越者身份。",
"sprite": "curator",
"next": "c1-01"
},
{
"id": "c1-01",
"type": "choice",
"pointId": "C1-01",
"prompt": "穿越者身份登记(将作为匹配的硬性过滤字段,见 ADR-0003)",
"dimensions": [],
"options": [
{ "id": "profile-a", "label": "杭州 · 28 · 女 · 互联网产品经理 · 硕士", "next": "c1-d3" },
{ "id": "profile-b", "label": "杭州 · 31 · 男 · 高校青年教师 · 博士", "next": "c1-d3" }
]
},
{
"id": "c1-d3",
"type": "dialogue",
"speaker": "馆长",
"text": "登记完成。穿越舱前有一扇门——离别之门。它不收钥匙,只收一段真实的离别作祭。",
"sprite": "curator",
"next": "c1-02"
},
{
"id": "c1-02",
"type": "narration",
"pointId": "C1-02",
"mechanism": "记忆钥匙",
"prompt": "讲一段你真实经历过的离别。是谁都可以,是哪一年都可以。门只听真的。",
"dimensions": ["attachment", "self_disclosure"],
"followUp": {
"maxQuestions": 1,
"strategy": "Jev 检测五感/人物/时空/情绪缺失维度,馆长口吻追问一句(MVP 未接,占位)"
},
"variants": [
{ "signature": "温暖×永失", "variantId": "sealed_love_tomb", "label": "「爱被封存」墓室", "tint": "#d4a96a" },
{ "signature": "悲恸", "variantId": "war_graves", "label": "「战乱乱冢」", "tint": "#5a5a72" },
{ "signature": "释然", "variantId": "empty_post_station", "label": "「远行者的空驿」", "tint": "#7ba7a0" },
{ "signature": "怨怼", "variantId": "desecrated_niche", "label": "「被凿毁的合葬龛」", "tint": "#8a4b4b" }
],
"next": "c1-d4"
},
{
"id": "c1-d4",
"type": "dialogue",
"speaker": "馆长",
"text": "门开了。你讲的那段离别,会在这座城的某个角落里等你——别问是哪,到了就知道。",
"sprite": "curator",
"next": "c1-03"
},
{
"id": "c1-03",
"type": "choice",
"pointId": "C1-03",
"prompt": "馆长最后问一句:你为什么接这个任务?",
"dimensions": ["values"],
"options": [
{ "id": "curiosity", "label": "求知——苍梧是我研究了五年的谜", "next": "c1-d5" },
{ "id": "adventure", "label": "冒险——这辈子总得疯一次", "next": "c1-d5" },
{ "id": "escape", "label": "逃避——现实里有些事先放一放", "next": "c1-d5" },
{ "id": "entrusted", "label": "有人托付——我答应过一个人", "next": "c1-d5" }
]
},
{
"id": "c1-d5",
"type": "dialogue",
"speaker": "老鬼",
"text": "穿越舱外,资源官老鬼叼着草根凑过来:「我说,到了那边顺手带点『纪念品』回来,苍梧的玉,一块够吃三年。怎么样?」队里没人表态,都看着你。",
"sprite": "laogui",
"next": "c1-04"
},
{
"id": "c1-04",
"type": "choice",
"pointId": "C1-04",
"prompt": "老鬼的提议悬在空气里,你接不接话?",
"dimensions": ["values", "conflict_style"],
"options": [
{ "id": "refuse", "label": "「我们是去记录的,不是去拿的。」当场回绝", "next": "c1-d6" },
{ "id": "joke", "label": "「先想想怎么活着回来吧。」打个岔混过去", "next": "c1-d6" },
{ "id": "silent", "label": "不接话,登舱", "next": "c1-d6" }
]
},
{
"id": "c1-d6",
"type": "dialogue",
"speaker": "沈铎",
"text": "队长沈铎递来一块残碑拓片:「穿越落点要靠碑文校准。你是研究苍梧的,这几个字,认一认。」",
"sprite": "shenduo",
"next": "c1-05"
},
{
"id": "c1-05",
"type": "choice",
"pointId": "C1-05",
"prompt": "残碑上刻着「惟苍之梧,□祀于□」。两个缺字怎么补?",
"dimensions": ["culture"],
"options": [
{ "id": "easy", "label": "补「燎 / 河」——祭祀常见搭配,稳妥(低难度)", "next": "c1-d7" },
{ "id": "medium", "label": "补「禘 / 丘」——苍梧多丘陵,禘祭合礼制(中难度)", "next": "c1-d7" },
{ "id": "hard", "label": "存疑不补——缺字处留白才是考古的态度(高难度)", "next": "c1-d7" }
]
},
{
"id": "c1-d7",
"type": "dialogue",
"speaker": "沈铎",
"text": "「最后一个参数:落点。稳妥落点在城外五里,走官道进城与接头人遇合;精确落点直接落在城内——省时,但那片城区的记录是空白,风险未知。」",
"sprite": "shenduo",
"next": "c1-06"
},
{
"id": "c1-06",
"type": "choice",
"pointId": "C1-06",
"prompt": "穿越落点,选哪个?",
"dimensions": ["risk"],
"options": [
{ "id": "safe", "label": "稳妥落点:城外遇合,按规程来", "next": "c1-cutscene" },
{ "id": "precise", "label": "精确落点:直接进城,赌一把", "next": "c1-cutscene" }
]
},
{
"id": "c1-cutscene",
"type": "cutscene",
"video": "cutscene-crossing",
"caption": "【预生成视频占位】穿越:光带收束,千年在一秒内倒退。",
"next": "c1-end"
},
{
"id": "c1-end",
"type": "chapterEnd",
"nextChapter": "chapter2"
}
]
}
+154
View File
@@ -0,0 +1,154 @@
{
"id": "chapter2",
"act": 1,
"title": "第 2 章 · 入墟",
"startNodeId": "c2-d1",
"nodes": [
{
"id": "c2-d1",
"type": "dialogue",
"speaker": "阿棠",
"text": "「到了到了到了!」阿棠第一个跳出舱门。苍梧城墟在暮色里趴着,安静得像屏住呼吸。小队开始建营。",
"sprite": "atang",
"next": "c2-d2"
},
{
"id": "c2-d2",
"type": "dialogue",
"speaker": "温简",
"text": "记录员温简清点物资时皱了眉:驱虫药粉只够两人份,但今晚守夜的是三个人。老鬼说按「贡献」分,阿棠说给最弱的那个。",
"sprite": "wenjian",
"next": "c2-01"
},
{
"id": "c2-01",
"type": "choice",
"pointId": "C2-01",
"prompt": "两人份的药粉,三个人要。你倾向怎么分?",
"dimensions": ["values", "conflict_style"],
"options": [
{ "id": "contribution", "label": "按贡献分——守夜任务重的优先", "next": "c2-d3" },
{ "id": "weakest", "label": "给最弱的——身体差的优先", "next": "c2-d3" },
{ "id": "share", "label": "都不用了,三人匀一匀凑合", "next": "c2-d3" },
{ "id": "delegate", "label": "让沈铎定,队长说了算", "next": "c2-d3" }
]
},
{
"id": "c2-d3",
"type": "dialogue",
"speaker": "旁白",
"text": "营地建好的傍晚,你们在城墙根遇见一位守墓老人——苍梧国最后的守陵人。他不要钱,不要粮。他说:「我收故事。」",
"sprite": "scene",
"next": "c2-02"
},
{
"id": "c2-02",
"type": "narration",
"pointId": "C2-02",
"mechanism": "故事换情报",
"prompt": "老人眯着眼问:「讲一个你这辈子最后悔的决定。讲得越真,我给的图就越细。」",
"dimensions": ["values", "emotional_stability"],
"followUp": {
"maxQuestions": 1,
"strategy": "Jev 检测五感/人物/时空/情绪缺失维度,老人口吻追问一句(MVP 未接,占位)"
},
"variants": [
{ "signature": "自责", "variantId": "intel_full", "label": "老人展开整张手绘城图(情报详细度·上)", "tint": "#6a8a6a" },
{ "signature": "遗憾但坦然", "variantId": "intel_mid", "label": "老人指出三条关键巷道(情报详细度·中)", "tint": "#a0964b" },
{ "signature": "愤怒指向他人", "variantId": "intel_low", "label": "老人只划了一条出城的路(情报详细度·下)", "tint": "#9a5b4b" }
],
"next": "c2-d4"
},
{
"id": "c2-d4",
"type": "dialogue",
"speaker": "沈铎",
"text": "次日进墟。岔路口,沈铎指地图:「按规程走大路,多两个时辰。」阿棠按住另一边:「我直觉走这条巷子,近一半!」队里的目光都落到你身上。",
"sprite": "shenduo",
"next": "c2-03"
},
{
"id": "c2-03",
"type": "choice",
"pointId": "C2-03",
"prompt": "稳妥路线 vs 直觉捷径,你站哪边?",
"dimensions": ["risk", "conflict_style"],
"options": [
{ "id": "with-shenduo", "label": "站沈铎:规程是用命换来的", "next": "c2-d5" },
{ "id": "with-atang", "label": "站阿棠:时间也是命", "next": "c2-d5" },
{ "id": "compromise", "label": "先派一人探巷,大队走大路", "next": "c2-d5" }
]
},
{
"id": "c2-d5",
"type": "dialogue",
"speaker": "旁白",
"text": "墟内一座观星台上,穹顶星象图被震落了半边。沈铎说:「复原它,下面可能有东西。」",
"sprite": "scene",
"next": "c2-04"
},
{
"id": "c2-04",
"type": "choice",
"pointId": "C2-04",
"prompt": "星象图缺了紫微垣一角。按苍梧的年代与天赤道位置,缺的应是哪组星?",
"dimensions": ["culture"],
"options": [
{ "id": "astronomy", "label": "勾陈一附近——以岁差推当时北极所在", "next": "c2-d6" },
{ "id": "pattern", "label": "顺着残图的对称规律补,几何上最顺", "next": "c2-d6" },
{ "id": "guess", "label": "凭感觉补一颗最亮的,先试试", "next": "c2-d6" }
]
},
{
"id": "c2-d6",
"type": "dialogue",
"speaker": "温简",
"text": "夜里篝火。温简忽然讲起他人生至暗的那年——母亲病重、学业中断、一个人在医院走廊里过完除夕。火光跳动,他看向你:「轮到你了。」",
"sprite": "wenjian",
"next": "c2-05"
},
{
"id": "c2-05",
"type": "narration",
"pointId": "C2-05",
"mechanism": "篝火互惠",
"prompt": "篝火互惠:他先把自己的至暗交给了你。你可以讲一段自己的,也可以沉默——沉默也是回答。",
"dimensions": ["attachment", "reciprocity"],
"followUp": {
"maxQuestions": 1,
"strategy": "Jev 检测五感/人物/时空/情绪缺失维度,温简口吻追问一句(MVP 未接,占位)"
},
"variants": [
{ "signature": "深暴露", "variantId": "wenjian_close_early", "label": "温简当夜就悄悄告诉你一个档案馆的秘密", "tint": "#d4844b" },
{ "signature": "浅暴露", "variantId": "wenjian_close_late", "label": "温简点点头,秘密留到下一章再说", "tint": "#8a7a5a" },
{ "signature": "沉默", "variantId": "wenjian_distant", "label": "温简替你拨了拨火,什么都没问", "tint": "#4b4b5a" }
],
"next": "c2-d7"
},
{
"id": "c2-d7",
"type": "dialogue",
"speaker": "旁白",
"text": "后半夜你起来添柴,撞见老鬼借着火光端详一枚玉佩——苍梧的卷云纹,白天刚出土的那枚。他明明登记过「无发现」。他也看见了你。",
"sprite": "laogui",
"next": "c2-06"
},
{
"id": "c2-06",
"type": "choice",
"pointId": "C2-06",
"prompt": "老鬼私藏玉佩,人赃并见你。怎么办?",
"dimensions": ["conflict_style", "values"],
"options": [
{ "id": "expose", "label": "当场揭发:把所有人都叫醒", "next": "c2-end" },
{ "id": "confront", "label": "私下对质:「放回去,我当没看见。」", "next": "c2-end" },
{ "id": "silence", "label": "暂时沉默:记住这事,先不惊动他", "next": "c2-end" }
]
},
{
"id": "c2-end",
"type": "chapterEnd",
"nextChapter": "chapter3"
}
]
}
+148
View File
@@ -0,0 +1,148 @@
{
"id": "chapter3",
"act": 1,
"title": "第 3 章 · 问心",
"startNodeId": "c3-d1",
"nodes": [
{
"id": "c3-d1",
"type": "dialogue",
"speaker": "旁白",
"text": "星象图下果然有暗道,通向宫墟深处。主墓室前的祭台上,出土器物排了一地:鼎、豆、爵、玉璧。沈铎说,还原仪式流程,机关才会开。",
"sprite": "scene",
"next": "c3-01"
},
{
"id": "c3-01",
"type": "choice",
"pointId": "C3-01",
"prompt": "按苍梧礼制,这组器物的仪式流程最可能是?",
"dimensions": ["culture"],
"options": [
{ "id": "rite-order", "label": "先奠玉璧,再陈鼎豆,最后以爵行酒——由尊及用", "next": "c3-d2" },
{ "id": "usage-order", "label": "先用爵酒祭地,再列鼎豆——由用及尊", "next": "c3-d2" },
{ "id": "follow-inscription", "label": "不猜流程,先读鼎内铭文找线索", "next": "c3-d2" }
]
},
{
"id": "c3-d2",
"type": "dialogue",
"speaker": "旁白",
"text": "机关转动,墓室石门开了。门后的机关却先「照」向了你——问心幻境,古墓读心。你在离别之门讲过的那段记忆,正在雾里成形。",
"sprite": "scene",
"next": "c3-02"
},
{
"id": "c3-02",
"type": "narration",
"pointId": "C3-02",
"mechanism": "问心幻境",
"prompt": "幻境里是你 C1-02 讲过的那段离别。通关条件只有一个:在幻境里,把那场告别说完。",
"dimensions": ["emotional_stability", "attachment"],
"followUp": {
"maxQuestions": 1,
"strategy": "Jev 检测五感/人物/时空/情绪缺失维度,幻境中故人口吻追问一句(MVP 未接,占位)"
},
"variantFrom": "C1-02",
"variants": [
{ "signature": "温暖×永失", "variantId": "sealed_love_tomb", "label": "「爱被封存」墓室——封存之物逐一亮起", "tint": "#d4a96a" },
{ "signature": "悲恸", "variantId": "war_graves", "label": "「战乱乱冢」——雾里传来姓名被念出的声音", "tint": "#5a5a72" },
{ "signature": "释然", "variantId": "empty_post_station", "label": "「远行者的空驿」——驿外终于来了迟到的马", "tint": "#7ba7a0" },
{ "signature": "怨怼", "variantId": "desecrated_niche", "label": "「被凿毁的合葬龛」——凿痕下露出原来的刻字", "tint": "#8a4b4b" }
],
"next": "c3-cutscene"
},
{
"id": "c3-cutscene",
"type": "cutscene",
"video": "cutscene-illusion-clear",
"caption": "【预生成视频占位】幻境通关:雾散,主墓室全貌显现。记忆越具体,幻境越丰满。",
"next": "c3-d3"
},
{
"id": "c3-d3",
"type": "dialogue",
"speaker": "阿棠",
"text": "还没站稳,头顶传来闷响——塌方征兆!尘土簌簌往下掉。阿棠在甬道那头喊你,沈铎扑向刚出土的核心文物箱。你只能先顾一头。",
"sprite": "atang",
"next": "c3-03"
},
{
"id": "c3-03",
"type": "choice",
"pointId": "C3-03",
"prompt": "塌方将至:先救队友,还是先保住文物?",
"dimensions": ["values", "emotional_stability"],
"options": [
{ "id": "save-people", "label": "先救人——文物塌了还能再挖", "next": "c3-d4" },
{ "id": "save-artifact", "label": "先保文物——它塌了就永远没了", "next": "c3-d4" },
{ "id": "split", "label": "吼一嗓子指挥:各顾最近的,边撤边抢", "next": "c3-d4" }
]
},
{
"id": "c3-d4",
"type": "dialogue",
"speaker": "沈铎",
"text": "撤回地面,清点时那枚玉佩的事发了——老鬼私藏被全队知晓。沈铎脸沉如水:「按规程,我要上报档案馆。你那天夜里在场,你的证词很关键。」",
"sprite": "shenduo",
"next": "c3-04"
},
{
"id": "c3-04",
"type": "choice",
"pointId": "C3-04",
"prompt": "沈铎要你作证上报老鬼。你怎么办?",
"dimensions": ["conflict_style", "values"],
"options": [
{ "id": "testify", "label": "作证:看见什么说什么", "next": "c3-d5" },
{ "id": "plead", "label": "求情:玉佩已追回,给他一个改过的机会", "next": "c3-d5" },
{ "id": "abstain", "label": "沉默:「我当时没看清。」", "next": "c3-d5" }
]
},
{
"id": "c3-d5",
"type": "dialogue",
"speaker": "阿棠",
"text": "撤离路线只有一条湿滑的石梁。阿棠把全队唯一的逃生绳塞进你手里,背对着你蹲下:「我先过,绳子你收着。我信你。」",
"sprite": "atang",
"next": "c3-05"
},
{
"id": "c3-05",
"type": "choice",
"pointId": "C3-05",
"prompt": "阿棠把后背交给了你。你握着绳子——",
"dimensions": ["mate_preference"],
"options": [
{ "id": "steady", "label": "稳稳收绳,让她一步一停地过", "next": "c3-d6" },
{ "id": "encourage", "label": "「别怕,我看着呢。」让她按自己节奏来", "next": "c3-d6" },
{ "id": "swap", "label": "把绳子转交给更有力气的沈铎,自己断后", "next": "c3-d6" }
]
},
{
"id": "c3-d6",
"type": "dialogue",
"speaker": "旁白",
"text": "任务收尾的深夜,你无意听到馆长的通讯频道——他在和一个未知方通话:「……采集员的记忆数据先不要同步,那边还没决定要不要让他们知道苍梧的真相。」档案馆,对你隐瞒了什么。",
"sprite": "curator",
"next": "c3-06"
},
{
"id": "c3-06",
"type": "choice",
"pointId": "C3-06",
"prompt": "章末钩子:档案馆的隐瞒,你打算怎么办?",
"dimensions": ["attachment", "mate_preference"],
"options": [
{ "id": "ask-curator", "label": "直接追问馆长:「苍梧的真相是什么?」", "next": "c3-end" },
{ "id": "keep-down", "label": "按下不表:装作没听到,先观察", "next": "c3-end" },
{ "id": "tell-wenjian", "label": "告诉温简——他知道的档案馆秘密也许对得上", "next": "c3-end" },
{ "id": "tell-atang", "label": "告诉阿棠——她信你,你也想信她一次", "next": "c3-end" }
]
},
{
"id": "c3-end",
"type": "chapterEnd"
}
]
}
+138
View File
@@ -0,0 +1,138 @@
# 故事框架 · Season 1「苍梧疑冢」
> 母本文档:剧本、测量、工程三方的共同依据。先定全图,再逐章写作。
> 关联:`CONTEXT.md`(领域语言)、`docs/adr/0003`(三通道生成边界)、`docs/adr/0004`(数据规则)。
## 0. 元框架:时空口述档案馆
近未来,"时空档案馆"致力于采集即将湮灭的历史现场。档案馆选中玩家成为
**口述历史采集员**:你讲述的真实人生是穿越旅程的燃料。每次任务归来,
档案馆"采集你的记忆";你也可以用自己的人生故事兑换下一段旅程。
**设定红线**:主角方永远是**记录与保护者**,不是掠夺者(合规口径,见 ADR-0001)。
### 常驻 NPC(元框架层)
- **馆长**(只闻其声):档案馆的人格化界面。负责身份登记、任务简报、
记忆采集与**追问**(所有追问以它的口吻发出,见 CONTEXT「追问补全」)。
语气:温和、好奇、偶尔意味深长。
## 1. Season 1 设定
**苍梧国**:架空古国(不用真实朝代,规避历史敏感与合规风险),
约千年前突然消亡,只余一座未被盗扰的城墟「苍梧遗址」。
玩家在现实中是省考古所研究员,研究方向正是苍梧;档案馆的第一个任务:
**穿越回苍梧国消亡前的最后三十天**,采集这座城市的口述史。
考古学知识(碑文、星象、营造、礼制)在穿越后的世界里直接有用——
解谜线的合法性来源。
### 穿越小队(人际线载体,全员采集员)
| 角色 | 原型 | 在测量中的功能 |
|---|---|---|
| **沈铎**(队长) | 冷静理性,规则至上 | 制造"规则 vs 人情"两难;玩家的服从/挑战对象是冲突风格信号 |
| **阿棠**(搭档,女) | 热情冲动,直觉派 | 制造"稳妥 vs 冒险"两难;高自我暴露,拉近玩家暴露程度的镜子 |
| **温简**(记录员,男) | 温和细腻,善于倾听 | **篝火互惠**的对手戏角色;他的先自我暴露是剧本写好的 |
| **老鬼**(资源官) | 世故圆滑,利益导向 | 制造道德灰色地带(私藏、交易、瞒报),价值观测量的压力源 |
### 古代侧 NPC
- **守墓老人**:苍梧国最后的守陵人。**故事换情报**机制的载体——
他不收钱,收故事。
## 2. 三季弧线(全图)
- **Act 1(第 1-3 章)「入队」**:登记与初征。建立小队关系,完成第一次穿越,
触及苍梧消亡的表层真相。结尾钩子:玩家发现档案馆对任务**有所隐瞒**。
- **Act 2(第 4-6 章)「裂缝」**:队内信任危机全面爆发(老鬼的私心、沈铎的
隐瞒、阿棠的莽撞酿祸),玩家在人际废墟中逼近真相中层。
- **Act 3(第 7-9 章)「问心」**:苍梧消亡的终极真相与玩家自身记忆产生镜像,
最高密度的幻境戏;玩家对"档案馆是否值得信任"做出终极抉择。
## 3. 实测维度(7 维,全季每维换皮测 3-5 次)
依恋类型 / 冲突处理风格 / 价值观与人生优先级 / 情绪稳定性 /
风险偏好 / 文化底蕴(解谜线) / 择偶偏好(实测版,经由对 NPC 的信任与托付行为)。
设计铁律(见 CONTEXT):**消除正确答案**;换皮复测;只测有预测力的维度;
**可玩性 > 测量覆盖**,支线必须作为剧情独立成立。
## 4. Act 1 选择点地图
> 选择点 ID 规则:`C{章}-{序}`。类型:按钮式(B)/ 语音讲述点(V)。
> 每章 B 类 5-7 个,V 类 1-2 个。讲述点 30 秒内必须给即时回报。
### 第 1 章「登记」(现实 → 档案馆 → 启程)
| ID | 类型 | 内容(一行) | 承载维度 |
|---|---|---|---|
| C1-01 | B | 穿越者身份登记:城市/年龄/性别/职业/学历(同时是匹配硬过滤字段,见 ADR-0003) | —(字段采集) |
| C1-02 | V | **记忆钥匙·离别之门**:"以一段真实的离别祭之" | 依恋类型 #1、自我暴露深度 |
| C1-03 | B | 馆长问任务动机,四选项无对错(求知/冒险/逃避现实/有人托付) | 价值观 #1 |
| C1-04 | B | 初见小队:老鬼提议"带点纪念品回来",无人表态,你接不接话 | 价值观 #2(金钱观)、冲突风格 #1 |
| C1-05 | B | 解谜线入门:残碑文字辨认(真实古文字知识,三档难度自适应) | 文化底蕴 #1 |
| C1-06 | B | 穿越参数选择:稳妥落点(城外遇合)vs 精确落点(直接进城,风险未知) | 风险偏好 #1 |
### 第 2 章「入墟」(抵达苍梧,建立营地)
| ID | 类型 | 内容 | 承载维度 |
|---|---|---|---|
| C2-01 | B | 物资分配:药粉只够两人份,三人需要(老鬼主张按"贡献",阿棠主张给最弱的) | 价值观 #3、冲突风格 #2 |
| C2-02 | V | **故事换情报·守墓老人**:"讲一个你这辈子最后悔的决定" | 价值观(悔的模式)、情绪稳定性 #1 |
| C2-03 | B | 路线分歧:沈铎的稳妥路线 vs 阿棠的直觉捷径,队里目光都落在你身上 | 风险偏好 #2、冲突风格 #3 |
| C2-04 | B | 解谜线:星象图复原(真实天文知识) | 文化底蕴 #2 |
| C2-05 | V | **篝火互惠**:温简先讲了他的人生至暗,火光看向你:"轮到你了。" | 依恋类型 #2、互惠倾向 |
| C2-06 | B | 夜里发现老鬼私藏了一枚苍梧玉佩:当场揭发 / 私下对质 / 暂时沉默 | 冲突风格 #4、价值观 #4 |
### 第 3 章「问心」(宫墟深处,Act 1 高潮)
| ID | 类型 | 内容 | 承载维度 |
|---|---|---|---|
| C3-01 | B | 解谜线:礼制还原(以出土器物推断仪式流程) | 文化底蕴 #3 |
| C3-02 | V | **问心幻境**(三通道之通道 2):墓室机关"照见"玩家 C1-02 讲述的记忆意象,通关条件是在幻境中完成告别 | 情绪稳定性 #2、依恋类型 #3 |
| C3-03 | B | 幻境外突发塌方征兆:先救队友 vs 先保住刚出土的核心文物 | 价值观 #5、情绪稳定性 #3 |
| C3-04 | B | 老鬼私藏事发,沈铎要按规程上报档案馆:你作证 / 求情 / 沉默 | 冲突风格 #5、价值观 #6 |
| C3-05 | B | 阿棠对你交付后背:把唯一的逃生绳交给你保管 | 择偶偏好 #1(信任行为) |
| C3-06 | B | 章末钩子:你无意听到馆长与未知方通话——档案馆隐瞒了什么。追问馆长 / 按下不表 / 告诉某个队友(选谁) | 依恋类型 #4(求助行为)、择偶偏好 #2 |
> 择偶偏好维度以行为代理测量:信任谁、把关键任务交给谁、危机时先救谁、
> 向谁吐露秘密。全部经由 NPC 交互间接采集,玩家无感知。
## 5. 记忆钥匙点与预写变体(通道 1)
每个 V 类讲述点配 2-4 个**预写场景变体**,由 Jev 提取的情绪签名选择:
| 讲述点 | 情绪签名维度 | 预写变体(基调) |
|---|---|---|
| C1-02 离别之门 | 温暖×永失 / 悲恸 / 释然 / 怨怼 | "爱被封存"墓室 / "战乱乱冢" / "远行者的空驿" / "被凿毁的合葬龛" |
| C2-02 最后悔的决定 | 自责 / 遗憾但坦然 / 愤怒指向他人 | 老人回赠的情报详细度三档 + 对应夜游场景变体 |
| C2-05 篝火 | 深暴露 / 浅暴露 / 沉默 | 温简后续亲密度分支(他分享档案馆秘密的早晚) |
| C3-02 问心幻境 | 由 C1-02 记忆内容驱动 | 幻境即 BOSS 战:记忆越具体,幻境越丰满(ADR-0004:记不清=幻境模糊,设定自洽) |
## 6. 自适应路由规则(Jev 置信度驱动)
每章结束后,以该用户全部行为轨迹为状态,一次 Jev 调用并行问 7 个维度的
score 问题。对 `confidence < 0.6` 的维度,在下一章插入对应支线:
| 低置信维度 | 插入支线(必须作为剧情独立成立) |
|---|---|
| 依恋类型 | 阿棠因任务需要与你短暂分离/失联的桥段 |
| 冲突风格 | 老鬼与新 NPC 行商的利益冲突,拉你评理 |
| 情绪稳定性 | 计划外突发事件(沙暴、机关误触) |
| 价值观 | 稀缺资源再分配事件 |
| 文化底蕴 | 加开一条解谜密度更高的墓道支线 |
## 7. 章节间预生成内容清单(工程依赖)
- 通道 1:下一章场景变体的**选择**(Jev 情绪签名 → 变体 ID)。
- 通道 2:幻境场景的文本与分镜(LLM 生成,失败回退通用幻境)。
- 通道 3:档案馆记忆展厅更新(用户讲述的陈列)。
- 追问:本章 V 类讲述点的缺失维度检测(Jev:五感/人物/时空/情绪)→
馆长口吻追问一句(LLM),至多 1-2 句。
- 画像报告(MVP 交付物):Act 1 结束后生成 7 维画像 + 勋章分享卡。
## 8. MVP 边界
交付:第 1-3 章(本文档定义的全部选择点与讲述点)+ 画像报告 + 分享卡。
不含:匹配、聊天、Act 2/3、实时生成(全部走章节间预生成)。
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>苍梧疑冢 · 互动剧情测评</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "jev-dating-match",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview",
"tauri": "tauri",
"verify": "tsx scripts/verify.ts"
},
"dependencies": {
"@tauri-apps/api": "^2.12.1",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tauri-apps/cli": "^2.12.1",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"tsx": "^4.23.15",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+1180
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+171
View File
@@ -0,0 +1,171 @@
/**
* 内容完整性与引擎流程验证(不走浏览器):
* 1. 章节 JSON 的选择点 ID、维度标签与 docs/story-framework.md 第 4 节一一对应;
* 2. 节点图完整(startNodeId 存在、所有 next 跳转有效、全部节点可达);
* 3. 用 MockJevClient + 内存 EventStore 跑通三章 → 16 个选择点全部落事件 → Jev 7 维打分。
* 运行:pnpm verify
*/
import { readFileSync } from 'node:fs';
import { StoryEngine } from '../src/engine/engine';
import type { EventStore, StoryEvent } from '../src/engine/eventStore';
import type { Chapter, ChoiceNode, NarrationNode } from '../src/engine/types';
import { MockJevClient } from '../src/services/jev';
// story-framework.md 第 4 节的权威选择点地图(ID → 期望维度标签)
const EXPECTED: Record<string, string[]> = {
'C1-01': [],
'C1-02': ['attachment', 'self_disclosure'],
'C1-03': ['values'],
'C1-04': ['values', 'conflict_style'],
'C1-05': ['culture'],
'C1-06': ['risk'],
'C2-01': ['values', 'conflict_style'],
'C2-02': ['values', 'emotional_stability'],
'C2-03': ['risk', 'conflict_style'],
'C2-04': ['culture'],
'C2-05': ['attachment', 'reciprocity'],
'C2-06': ['conflict_style', 'values'],
'C3-01': ['culture'],
'C3-02': ['emotional_stability', 'attachment'],
'C3-03': ['values', 'emotional_stability'],
'C3-04': ['conflict_style', 'values'],
'C3-05': ['mate_preference'],
'C3-06': ['attachment', 'mate_preference'],
};
class MemoryStore implements EventStore {
events: StoryEvent[] = [];
async append(e: StoryEvent) {
this.events.push(e);
}
async list(sessionId?: string) {
return sessionId ? this.events.filter((e) => e.sessionId === sessionId) : [...this.events];
}
}
const chapters = [1, 2, 3].map(
(i) => JSON.parse(readFileSync(new URL(`../content/chapters/chapter${i}.json`, import.meta.url), 'utf8')) as Chapter,
);
let failures = 0;
function check(ok: boolean, msg: string) {
if (!ok) {
failures++;
console.error(`✗ ${msg}`);
} else {
console.log(`✓ ${msg}`);
}
}
// --- 1. 结构校验 ---
const seenPoints = new Map<string, string[]>();
for (const ch of chapters) {
const ids = new Set(ch.nodes.map((n) => n.id));
check(ids.has(ch.startNodeId), `${ch.id}: startNodeId 存在`);
const reachable = new Set<string>([ch.startNodeId]);
for (let changed = true; changed; ) {
changed = false;
for (const n of ch.nodes) {
if (!reachable.has(n.id)) continue;
const nexts: string[] =
n.type === 'choice'
? n.options.map((o) => o.next)
: n.type === 'chapterEnd'
? []
: [n.next];
for (const nx of nexts) {
check(ids.has(nx), `${ch.id}/${n.id}: next "${nx}" 有效`);
if (!reachable.has(nx)) {
reachable.add(nx);
changed = true;
}
}
}
}
check(reachable.size === ch.nodes.length, `${ch.id}: 全部 ${ch.nodes.length} 个节点可达`);
for (const n of ch.nodes) {
if (n.type === 'choice' || n.type === 'narration') {
check(!seenPoints.has(n.pointId), `${n.pointId}: 选择点 ID 全局唯一`);
seenPoints.set(n.pointId, n.dimensions);
}
}
}
// --- 2. 与母本选择点地图对照 ---
const expectedIds = Object.keys(EXPECTED).sort();
const actualIds = [...seenPoints.keys()].sort();
check(JSON.stringify(actualIds) === JSON.stringify(expectedIds), `选择点 ID 集合与母本一致(${actualIds.length} 个)`);
for (const [id, dims] of Object.entries(EXPECTED)) {
check(
JSON.stringify(seenPoints.get(id) ?? null) === JSON.stringify(dims),
`${id}: 维度标签 ${JSON.stringify(seenPoints.get(id))} == 母本 ${JSON.stringify(dims)}`,
);
}
// --- 3. V 类讲述点变体结构 ---
const narrationNodes = chapters.flatMap((c) => c.nodes.filter((n) => n.type === 'narration')) as NarrationNode[];
check(narrationNodes.map((n) => n.pointId).sort().join(',') === 'C1-02,C2-02,C2-05,C3-02', 'V 类讲述点为 C1-02/C2-02/C2-05/C3-02');
for (const n of narrationNodes) {
check(n.variants.length >= 2, `${n.pointId}: 有 ${n.variants.length} 个预写场景变体`);
check(
n.variants.every((v) => v.signature && v.variantId && v.label),
`${n.pointId}: 情绪签名 → variantId 映射完整`,
);
}
check(narrationNodes.find((n) => n.pointId === 'C3-02')?.variantFrom === 'C1-02', 'C3-02 变体由 C1-02 驱动(variantFrom)');
// --- 4. Mock 模式端到端跑通三章 ---
const store = new MemoryStore();
const jev = new MockJevClient();
const engine = new StoryEngine(chapters, store, jev);
let guard = 0;
while (!engine.state.finished && guard++ < 200) {
const node = engine.currentNode();
if (node.type === 'choice') {
await engine.choose((node as ChoiceNode).options[0].id);
} else if (node.type === 'narration') {
// C2-05 走"沉默"路径验证合法选项,其余讲述
if ((node as NarrationNode).pointId === 'C2-05') await engine.skipNarration();
else await engine.submitNarration('(验证用占位讲述)');
} else {
engine.advance();
}
}
check(engine.state.finished, '三章流程跑通(Mock 模式)');
const eventPoints = store.events.map((e) => e.pointId).sort();
check(
JSON.stringify(eventPoints) === JSON.stringify(expectedIds),
`事件轨迹覆盖全部 ${expectedIds.length} 个选择点`,
);
const c305 = store.events.find((e) => e.pointId === 'C3-02');
const c102 = store.events.find((e) => e.pointId === 'C1-02');
check(!!c305?.variantId && c305.variantId === c102?.variantId, 'C3-02 幻境变体复用 C1-02 情绪解析结果');
// --- 5. 画像报告:7 维 score 问题 ---
const criteria = Array.from({ length: 10 }, (_, i) => String(i + 1));
const dims = ['attachment', 'conflict_style', 'values', 'emotional_stability', 'risk', 'culture', 'mate_preference'];
const answers = await jev.ask(
store.events,
Object.fromEntries(dims.map((d) => [d, { type: 'score' as const, description: d, criteria }])),
);
check(
dims.every((d) => answers[d]?.type === 'score' && answers[d].confidence >= 0 && answers[d].confidence <= 1),
'Jev 7 维 score 打分返回(含 0-1 置信度)',
);
// --- 6. 重玩语义:新轨迹不修改旧事件 ---
const before = store.events.length;
const oldSession = engine.state.sessionId;
engine.replay('chapter1');
check(engine.state.sessionId !== oldSession, '重玩产生新 sessionId');
while (engine.currentNode().type !== 'choice') engine.advance();
const firstChoice = engine.currentNode() as ChoiceNode;
await engine.choose(firstChoice.options[0].id);
check(store.events.length === before + 1, '重玩追加新事件,旧事件不变');
check((await store.list(oldSession)).length === before, '旧轨迹事件完整保留');
if (failures > 0) {
console.error(`\n${failures} 项校验失败`);
process.exit(1);
}
console.log('\n全部校验通过');
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+4468
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2024"
rust-version = "1.90"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.7.1" }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.12.1" }
tauri-plugin-log = "2"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+16
View File
@@ -0,0 +1,16 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while building tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}
+40
View File
@@ -0,0 +1,40 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "jev-dating-match",
"version": "0.1.0",
"identifier": "com.tauri.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build"
},
"app": {
"windows": [
{
"title": "苍梧疑冢",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"android": {
"debugApplicationIdSuffix": ".debug"
}
}
}
+279
View File
@@ -0,0 +1,279 @@
.app {
max-width: 720px;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ---- 影游场景 ---- */
.scene {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 24px;
background: linear-gradient(160deg, #14101e 30%, #2a2338 100%);
border-radius: 12px;
min-height: 80vh;
transition: background 0.8s ease;
}
.scene-header {
position: fixed;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
padding: 12px 20px;
font-size: 13px;
color: #b0a490;
background: rgba(20, 16, 30, 0.8);
}
.variant-tag {
color: #d4a96a;
}
.dialogue-box {
display: flex;
gap: 16px;
background: rgba(20, 16, 30, 0.85);
border: 1px solid #3a3248;
border-radius: 12px;
padding: 20px;
cursor: pointer;
}
.sprite {
flex-shrink: 0;
width: 72px;
height: 72px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: #f0e6d2;
}
.speaker-name {
font-weight: 700;
color: #d4a96a;
margin-bottom: 6px;
}
.dialogue-text {
line-height: 1.8;
margin: 0 0 8px;
}
.hint {
font-size: 12px;
color: #8a7f6a;
}
/* ---- 选择点 ---- */
.choice-box,
.narration-box {
background: rgba(20, 16, 30, 0.85);
border: 1px solid #3a3248;
border-radius: 12px;
padding: 20px;
}
.choice-prompt,
.narration-prompt {
font-size: 17px;
line-height: 1.7;
color: #f0e6d2;
}
.choice-options {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.choice-btn {
text-align: left;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #4a3f5a;
background: #241e33;
color: #e8dcc8;
font-size: 15px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.choice-btn:hover {
border-color: #d4a96a;
background: #2e2642;
}
/* ---- 讲述点 ---- */
.mechanism-tag {
display: inline-block;
font-size: 12px;
color: #1a1626;
background: #d4a96a;
border-radius: 4px;
padding: 2px 8px;
margin-bottom: 10px;
}
.hold-btn {
width: 100%;
margin-top: 12px;
padding: 18px;
border-radius: 999px;
border: 2px solid #d4a96a;
background: transparent;
color: #d4a96a;
font-size: 16px;
cursor: pointer;
user-select: none;
touch-action: none;
}
.hold-btn.holding {
background: #d4a96a;
color: #1a1626;
}
/* ---- 过场 ---- */
.cutscene {
text-align: center;
cursor: pointer;
}
.cutscene-placeholder {
border: 2px dashed #4a3f5a;
border-radius: 12px;
padding: 80px 20px;
font-size: 20px;
color: #8a7f6a;
margin-bottom: 12px;
}
/* ---- 通用按钮 ---- */
button.primary,
button.secondary {
margin-top: 16px;
padding: 12px 24px;
border-radius: 8px;
font-size: 15px;
cursor: pointer;
}
button.primary {
border: none;
background: #d4a96a;
color: #1a1626;
font-weight: 700;
}
button.secondary {
border: 1px solid #4a3f5a;
background: transparent;
color: #b0a490;
width: 100%;
margin-top: 10px;
}
.chapter-end {
text-align: center;
padding: 40px 0;
}
/* ---- 画像报告 ---- */
.report {
padding: 32px 24px;
}
.report h1 {
color: #d4a96a;
}
.badge-banner {
display: flex;
gap: 20px;
align-items: center;
background: rgba(212, 169, 106, 0.08);
border: 1px solid #d4a96a;
border-radius: 12px;
padding: 20px;
margin: 16px 0 24px;
}
.badge-medal {
width: 72px;
height: 72px;
border-radius: 50%;
background: #d4a96a;
color: #1a1626;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 700;
flex-shrink: 0;
}
.badge-note {
font-size: 13px;
color: #8a7f6a;
}
.dimension-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 28px;
}
.dimension-row {
display: grid;
grid-template-columns: 140px 1fr 190px;
align-items: center;
gap: 12px;
}
.dimension-label {
color: #e8dcc8;
}
.dimension-bar {
height: 14px;
background: #2a2338;
border-radius: 7px;
overflow: hidden;
}
.dimension-fill {
height: 100%;
background: #d4a96a;
}
.dimension-value {
font-size: 13px;
color: #b0a490;
}
/* ---- 分享卡 ---- */
.share-card {
text-align: center;
margin-bottom: 16px;
}
.share-canvas {
max-width: 320px;
width: 100%;
border-radius: 12px;
border: 1px solid #3a3248;
display: block;
margin: 0 auto 12px;
}
+40
View File
@@ -0,0 +1,40 @@
import { useMemo, useState } from 'react';
import './App.css';
import { StoryEngine } from './engine/engine';
import { LocalStorageEventStore } from './engine/eventStore';
import type { Chapter } from './engine/types';
import { getJevClient } from './services/jev';
import { StoryPlayer } from './ui/StoryPlayer';
import { ReportPage } from './ui/report/ReportPage';
import chapter1 from '../content/chapters/chapter1.json';
import chapter2 from '../content/chapters/chapter2.json';
import chapter3 from '../content/chapters/chapter3.json';
const chapters = [chapter1, chapter2, chapter3] as Chapter[];
export default function App() {
const [screen, setScreen] = useState<'story' | 'report'>('story');
// 引擎与存储整个会话只建一次;重玩走 engine.replay()(新 sessionId,旧事件保留)
const { engine, store, jev } = useMemo(() => {
const store = new LocalStorageEventStore();
const jev = getJevClient();
return { engine: new StoryEngine(chapters, store, jev), store, jev };
}, []);
return (
<div className="app">
{screen === 'story' ? (
<StoryPlayer engine={engine} onFinish={() => setScreen('report')} />
) : (
<ReportPage
store={store}
jev={jev}
onReplay={() => {
engine.replay('chapter1');
setScreen('story');
}}
/>
)}
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import type { Chapter, NarrationNode, SceneVariant, StoryNode } from './types';
import type { EventStore, StoryEvent } from './eventStore';
import { makeEvent } from './eventStore';
import type { JevClient } from '../services/jev';
export interface EngineState {
sessionId: string;
chapterId: string;
nodeId: string;
/** 当前场景变体(讲述点情绪签名解析结果,驱动 UI 场景配色) */
activeVariant: SceneVariant | null;
/** 全部三章完成后为 true → 进入画像报告 */
finished: boolean;
}
export interface NarrationResult {
variant: SceneVariant;
event: StoryEvent;
}
function newSessionId(): string {
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `s-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* 剧情引擎:状态机。当前章节/节点、按选择推进、事件落 EventStore。
* 与 UI 完全解耦——UI 只读 state、调 advance/choose/submitNarration。
*/
export class StoryEngine {
readonly state: EngineState;
private chapters: Chapter[];
private store: EventStore;
private jev: JevClient;
private nodeIndex: Map<string, StoryNode> = new Map();
constructor(chapters: Chapter[], store: EventStore, jev: JevClient, startChapterId?: string) {
if (chapters.length === 0) throw new Error('StoryEngine: no chapters');
this.chapters = chapters;
this.store = store;
this.jev = jev;
const start = chapters.find((c) => c.id === startChapterId) ?? chapters[0];
for (const c of chapters) {
for (const n of c.nodes) this.nodeIndex.set(`${c.id}/${n.id}`, n);
}
this.state = {
sessionId: newSessionId(),
chapterId: start.id,
nodeId: start.startNodeId,
activeVariant: null,
finished: false,
};
}
currentChapter(): Chapter {
const c = this.chapters.find((c) => c.id === this.state.chapterId);
if (!c) throw new Error(`unknown chapter ${this.state.chapterId}`);
return c;
}
currentNode(): StoryNode {
const n = this.nodeIndex.get(`${this.state.chapterId}/${this.state.nodeId}`);
if (!n) throw new Error(`unknown node ${this.state.chapterId}/${this.state.nodeId}`);
return n;
}
/** dialogue / cutscene / chapterEnd 前进一步 */
advance(): void {
const node = this.currentNode();
if (node.type === 'dialogue' || node.type === 'cutscene') {
this.state.nodeId = node.next;
} else if (node.type === 'chapterEnd') {
if (node.nextChapter) {
this.state.chapterId = node.nextChapter;
this.state.nodeId = this.currentChapter().startNodeId;
this.state.activeVariant = null;
} else {
this.state.finished = true;
}
} else {
throw new Error(`advance() 不适用于 ${node.type} 节点`);
}
}
/** B 类选择:落事件并按选项跳转 */
async choose(optionId: string): Promise<void> {
const node = this.currentNode();
if (node.type !== 'choice') throw new Error('当前节点不是选择点');
const option = node.options.find((o) => o.id === optionId);
if (!option) throw new Error(`未知选项 ${optionId}`);
await this.store.append(
makeEvent({
sessionId: this.state.sessionId,
chapterId: this.state.chapterId,
kind: 'choice',
pointId: node.pointId,
dimensions: node.dimensions,
optionId: option.id,
optionLabel: option.label,
}),
);
this.state.nodeId = option.next;
}
/**
* V 类讲述:落事件(transcript 为 ASR 占位,沉默为 null),
* 并解析情绪签名 → 场景变体(ADR-0003 通道 1,只做选择)。
*/
async submitNarration(transcript: string | null): Promise<NarrationResult> {
const node = this.currentNode();
if (node.type !== 'narration') throw new Error('当前节点不是讲述点');
const variant = await this.resolveVariant(node, transcript);
const event = makeEvent({
sessionId: this.state.sessionId,
chapterId: this.state.chapterId,
kind: 'narration',
pointId: node.pointId,
dimensions: node.dimensions,
variantId: variant.variantId,
emotionSignature: variant.signature,
transcript,
});
await this.store.append(event);
this.state.activeVariant = variant;
this.state.nodeId = node.next;
return { variant, event };
}
/** 沉默永远是合法选项(CONTEXT:在什么面前沉默本身也是数据) */
async skipNarration(): Promise<NarrationResult> {
return this.submitNarration(null);
}
private async resolveVariant(node: NarrationNode, transcript: string | null): Promise<SceneVariant> {
// variantFrom:复用此前讲述点的情绪解析(如 C3-02 问心幻境由 C1-02 记忆驱动)
if (node.variantFrom) {
const events = await this.store.list(this.state.sessionId);
const source = [...events].reverse().find((e) => e.pointId === node.variantFrom && e.variantId);
const match = source && node.variants.find((v) => v.variantId === source.variantId);
if (match) return match;
}
// 沉默 → 取"沉默/模糊"档变体(签名含"沉默"者),否则最后一档(幻境模糊,设定自洽)
if (transcript === null) {
const silent = node.variants.find((v) => v.signature.includes('沉默'));
return silent ?? node.variants[node.variants.length - 1];
}
// 通道 1:Jev 从讲述文本提取情绪签名,作为预写变体的选择器(只做选择题)
const answer = await this.jev.ask(
{ transcript, pointId: node.pointId },
{
emotion_signature: {
type: 'choice',
description: `根据讲述文本选择最贴合的情绪签名(讲述点 ${node.pointId})`,
options: node.variants.map((v) => v.signature),
},
},
);
const picked = answer.emotion_signature;
if (picked?.type === 'choice') {
const match = node.variants.find((v) => v.signature === picked.selected);
if (match) return match;
}
return node.variants[0];
}
/**
* 重玩(ADR-0004):产生新轨迹(新 sessionId),不修改旧事件。
* 默认重玩当前章,也可指定章节。
*/
replay(chapterId?: string): void {
const chapter = this.chapters.find((c) => c.id === (chapterId ?? this.state.chapterId));
if (!chapter) throw new Error(`unknown chapter ${chapterId}`);
this.state.sessionId = newSessionId();
this.state.chapterId = chapter.id;
this.state.nodeId = chapter.startNodeId;
this.state.activeVariant = null;
this.state.finished = false;
}
}
+70
View File
@@ -0,0 +1,70 @@
import type { DimensionTag } from './types';
/**
* 选择/讲述事件存储(ADR-0004:历史不可涂改)。
* 接口只有 append 和 list —— 刻意不提供 update/delete,
* 让"不允许单独编辑历史选择"成为类型层面的约束,而非约定。
*/
export interface EventStore {
append(event: StoryEvent): Promise<void>;
/** 列出事件;传 sessionId 则只看该轨迹 */
list(sessionId?: string): Promise<StoryEvent[]>;
}
/** 一次重玩 = 一条新轨迹(新 sessionId),旧事件原样保留 */
export interface StoryEvent {
id: string;
sessionId: string;
ts: number;
chapterId: string;
kind: 'choice' | 'narration';
/** 选择点 ID,如 C1-03 */
pointId: string;
dimensions: DimensionTag[];
/** choice 事件:所选选项 */
optionId?: string;
optionLabel?: string;
/** narration 事件:选中的场景变体(情绪签名 → variantId) */
variantId?: string;
emotionSignature?: string;
/** 讲述转写文本占位(ASR 未接,MVP 为手输或占位文本;沉默为 null) */
transcript?: string | null;
}
const STORAGE_KEY = 'jev-dating-match.events.v1';
function newId(): string {
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
export function makeEvent(partial: Omit<StoryEvent, 'id' | 'ts'>): StoryEvent {
return { ...partial, id: newId(), ts: Date.now() };
}
/** Web 端实现:localStorage。 */
export class LocalStorageEventStore implements EventStore {
async append(event: StoryEvent): Promise<void> {
const all = await this.list();
all.push(event);
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
}
async list(sessionId?: string): Promise<StoryEvent[]> {
let raw: string | null = null;
try {
raw = localStorage.getItem(STORAGE_KEY);
} catch {
return [];
}
const all: StoryEvent[] = raw ? (JSON.parse(raw) as StoryEvent[]) : [];
return sessionId ? all.filter((e) => e.sessionId === sessionId) : all;
}
}
/**
* Tauri 原生端实现预留:后续用 tauri-plugin-sql(SQLite)实现同一接口,
* 表结构即 StoryEvent 一行一条,只写 INSERT/SELECT,不建 UPDATE/DELETE 路径。
* 见 README「已知缺口」。
*/
+108
View File
@@ -0,0 +1,108 @@
/**
* 剧情引擎数据 Schema。
* 章节用 JSON 定义(content/chapters/*.json),引擎只消费这些类型,与 UI 解耦。
* 选择点 ID 规则:C{章}-{序},见 docs/story-framework.md 第 4 节。
*/
/** 实测维度标签(英文标签,对应 CONTEXT.md 7 维 + 辅助信号) */
export type DimensionTag =
| 'attachment' // 依恋类型
| 'conflict_style' // 冲突处理风格
| 'values' // 价值观与人生优先级
| 'emotional_stability' // 情绪稳定性
| 'risk' // 风险偏好
| 'culture' // 文化底蕴
| 'mate_preference' // 择偶偏好(行为代理)
| 'self_disclosure' // 自我暴露深度
| 'reciprocity'; // 互惠倾向
interface BaseNode {
id: string;
type: NodeType;
}
export type NodeType = 'dialogue' | 'choice' | 'narration' | 'cutscene' | 'chapterEnd';
/** 对话节点:角色 + 台词 + 立绘占位(sprite 仅作 CSS 色块标识) */
export interface DialogueNode extends BaseNode {
type: 'dialogue';
speaker: string;
text: string;
/** 立绘占位标识(无图片素材,UI 用色块渲染) */
sprite?: string;
next: string;
}
/** 按钮式选择点(B 类) */
export interface ChoiceNode extends BaseNode {
type: 'choice';
/** 选择点 ID,如 C1-03 */
pointId: string;
prompt: string;
/** 承载维度标签,如 ["values", "conflict_style"] */
dimensions: DimensionTag[];
options: ChoiceOption[];
}
export interface ChoiceOption {
id: string;
label: string;
next: string;
}
/** 情绪签名 → 预写场景变体(ADR-0003 通道 1:LLM/Jev 只做选择,不生成主线) */
export interface SceneVariant {
/** 情绪签名,如 "温暖×永失" */
signature: string;
variantId: string;
/** 场景基调文案 */
label: string;
/** UI 占位渐变主色 */
tint: string;
}
/** 语音讲述点(V 类):提示语 + 追问策略占位 + 情绪签名→变体选择 */
export interface NarrationNode extends BaseNode {
type: 'narration';
pointId: string;
/** 机制名:记忆钥匙 / 故事换情报 / 篝火互惠 / 问心幻境 */
mechanism: string;
/** 讲述提示语 */
prompt: string;
dimensions: DimensionTag[];
/** 追问策略占位(Jev 缺失维度检测 + 馆长口吻追问,至多 1-2 句,MVP 未接) */
followUp: { maxQuestions: number; strategy: string };
/** 预写场景变体表 */
variants: SceneVariant[];
/**
* 变体选择复用其他讲述点的结果(如 C3-02 由 C1-02 的记忆内容驱动)。
* 指向另一讲述点的 pointId。
*/
variantFrom?: string;
next: string;
}
/** 预生成视频占位节点 */
export interface CutsceneNode extends BaseNode {
type: 'cutscene';
/** 视频占位标识(MVP 无素材,UI 用占位渲染) */
video: string;
caption: string;
next: string;
}
export interface ChapterEndNode extends BaseNode {
type: 'chapterEnd';
/** 下一章 id;Act 1 结束则进入画像报告 */
nextChapter?: string;
}
export type StoryNode = DialogueNode | ChoiceNode | NarrationNode | CutsceneNode | ChapterEndNode;
export interface Chapter {
id: string;
act: number;
title: string;
startNodeId: string;
nodes: StoryNode[];
}
+31
View File
@@ -0,0 +1,31 @@
:root {
font: 16px/1.6 system-ui, 'PingFang SC', 'Segoe UI', sans-serif;
color: #d8ccb8;
background: #14101e;
color-scheme: dark;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
}
#root {
min-height: 100svh;
}
h1 {
font-size: 28px;
}
h2 {
font-size: 20px;
margin: 0 0 4px;
}
p {
margin: 0 0 6px;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+144
View File
@@ -0,0 +1,144 @@
/**
* Jev 服务层 —— TypeSafe AI 的 System One 模型。
* REST: POST https://api.typesafe.ai/v1/systemone
* Header: Authorization: Bearer <TYPESAFE_API_KEY>
* 请求: { model: "jev-latest", state: <文本或JSON>, questions: { <id>: Question } }
* 响应: answers[<id>] 含分数/概率分布/confidence(0-1)
*/
/** 是非概率问题 */
export interface NoulQuestion {
type: 'noul';
description: string;
}
/** 选择题(≤255 选项),criteria 为 map */
export interface ChoiceQuestion {
type: 'choice';
description: string;
options: string[];
criteria?: Record<string, string>;
}
/** 打分题,criteria 为 2-10 级有序数组(从低到高) */
export interface ScoreQuestion {
type: 'score';
description: string;
criteria: string[];
}
export type JevQuestion = NoulQuestion | ChoiceQuestion | ScoreQuestion;
export interface JevRequest {
model: 'jev-latest';
state: unknown;
questions: Record<string, JevQuestion>;
}
export type JevAnswer =
| { type: 'noul'; probability: number; confidence: number }
| { type: 'choice'; selected: string; distribution: Record<string, number>; confidence: number }
| { type: 'score'; score: number; min: number; max: number; confidence: number };
export interface JevClient {
ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>>;
}
const API_URL = 'https://api.typesafe.ai/v1/systemone';
/** 直连 REST 的实现。浏览器调用可能遇到 CORS,正式环境需走后端代理(见 README)。 */
export class HttpJevClient implements JevClient {
private apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
async ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
const res = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({ model: 'jev-latest', state, questions } satisfies JevRequest),
});
if (!res.ok) {
throw new Error(`Jev API ${res.status}: ${await res.text()}`);
}
const body = (await res.json()) as { answers: Record<string, JevAnswer> };
return body.answers;
}
}
/** 简单确定性 hash(FNV-1a),Mock 用它让同一 (state, question) 永远得到同一答案。 */
function hash(input: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
/** 由 hash 生成 [0,1) 的确定性伪随机序列 */
function prng(seed: number, n: number): number[] {
const out: number[] = [];
let s = seed;
for (let i = 0; i < n; i++) {
s = hash(String(s) + ':' + i);
out.push(s / 0xffffffff);
}
return out;
}
/** 无 key 开发用:确定性伪随机返分(同一输入永远同一输出,便于联调)。 */
export class MockJevClient implements JevClient {
async ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
const stateKey = JSON.stringify(state) ?? '';
const answers: Record<string, JevAnswer> = {};
for (const [id, q] of Object.entries(questions)) {
const seed = hash(stateKey + '|' + id);
const confidence = Math.round((0.35 + prng(seed, 1)[0] * 0.6) * 100) / 100;
if (q.type === 'noul') {
answers[id] = { type: 'noul', probability: Math.round(prng(seed, 2)[1] * 100) / 100, confidence };
} else if (q.type === 'choice') {
const r = prng(seed, q.options.length + 1);
const weights = r.slice(0, q.options.length);
const total = weights.reduce((a, b) => a + b, 0) || 1;
const distribution: Record<string, number> = {};
let selected = q.options[0];
let best = -1;
q.options.forEach((opt, i) => {
distribution[opt] = Math.round((weights[i] / total) * 100) / 100;
if (weights[i] > best) {
best = weights[i];
selected = opt;
}
});
answers[id] = { type: 'choice', selected, distribution, confidence };
} else {
const min = 1;
const max = q.criteria.length;
const score = min + Math.floor(prng(seed, 1)[0] * max);
answers[id] = { type: 'score', score, min, max, confidence };
}
}
return answers;
}
}
let singleton: JevClient | null = null;
/** key 缺失时自动降级 Mock 并提示。 */
export function getJevClient(): JevClient {
if (singleton) return singleton;
const key = import.meta.env.VITE_TYPESAFE_API_KEY as string | undefined;
if (key) {
singleton = new HttpJevClient(key);
} else {
console.warn('[jev] 未配置 VITE_TYPESAFE_API_KEY,降级为 MockJevClient(确定性伪随机返分)');
singleton = new MockJevClient();
}
return singleton;
}
+155
View File
@@ -0,0 +1,155 @@
import { useState } from 'react';
import type { StoryEngine } from '../engine/engine';
import type { ChoiceNode, NarrationNode } from '../engine/types';
/** speaker/sprite → 立绘占位色(无图片素材,CSS 色块) */
const SPRITE_COLORS: Record<string, string> = {
curator: '#6a5a8a',
shenduo: '#4a6a8a',
atang: '#c46a5a',
wenjian: '#7a9a8a',
laogui: '#8a7a4a',
scene: '#3a3a4a',
};
interface Props {
engine: StoryEngine;
onFinish: () => void;
}
/** 最简影游界面:对话框 / 选项按钮 / 讲述点按住说话占位 / 场景变体渐变 */
export function StoryPlayer({ engine, onFinish }: Props) {
// tick 只是触发重渲染;引擎是唯一事实源
const [tick, setTick] = useState(0);
const rerender = () => setTick((t) => t + 1);
void tick;
const node = engine.currentNode();
const chapter = engine.currentChapter();
const variant = engine.state.activeVariant;
return (
<div
className="scene"
style={
variant
? { background: `linear-gradient(160deg, #1a1626 30%, ${variant.tint} 130%)` }
: undefined
}
>
<header className="scene-header">
<span>{chapter.title}</span>
{variant && <span className="variant-tag">场景变体:{variant.label}</span>}
</header>
{node.type === 'dialogue' && (
<div className="dialogue-box" onClick={() => { engine.advance(); if (engine.state.finished) onFinish(); else rerender(); }}>
<div className="sprite" style={{ background: SPRITE_COLORS[node.sprite ?? 'scene'] }}>
{node.speaker.slice(0, 1)}
</div>
<div>
<div className="speaker-name">{node.speaker}</div>
<p className="dialogue-text">{node.text}</p>
<span className="hint">点击继续 ▸</span>
</div>
</div>
)}
{node.type === 'choice' && <ChoiceView node={node} onPick={async (id) => { await engine.choose(id); rerender(); }} />}
{node.type === 'narration' && (
<NarrationView
node={node}
onSubmit={async (text) => { await engine.submitNarration(text); rerender(); }}
onSkip={async () => { await engine.skipNarration(); rerender(); }}
/>
)}
{node.type === 'cutscene' && (
<div className="cutscene" onClick={() => { engine.advance(); rerender(); }}>
<div className="cutscene-placeholder">▶ {node.video}</div>
<p>{node.caption}</p>
<span className="hint">点击继续 ▸</span>
</div>
)}
{node.type === 'chapterEnd' && (
<div className="chapter-end">
<h2>{chapter.title} · 完</h2>
<button
className="primary"
onClick={() => {
engine.advance();
if (engine.state.finished) onFinish();
else rerender();
}}
>
{node.nextChapter ? '进入下一章' : '生成人格画像报告'}
</button>
</div>
)}
</div>
);
}
function ChoiceView({ node, onPick }: { node: ChoiceNode; onPick: (id: string) => void }) {
const [busy, setBusy] = useState(false);
return (
<div className="choice-box">
<p className="choice-prompt">{node.prompt}</p>
<div className="choice-options">
{node.options.map((o) => (
<button
key={o.id}
className="choice-btn"
disabled={busy}
onClick={async () => {
setBusy(true);
await onPick(o.id);
setBusy(false);
}}
>
{o.label}
</button>
))}
</div>
</div>
);
}
function NarrationView({
node,
onSubmit,
onSkip,
}: {
node: NarrationNode;
onSubmit: (text: string) => void;
onSkip: () => void;
}) {
const [holding, setHolding] = useState(false);
const [busy, setBusy] = useState(false);
return (
<div className="narration-box">
<div className="mechanism-tag">{node.mechanism} · 语音讲述点</div>
<p className="narration-prompt">{node.prompt}</p>
<button
className={`hold-btn ${holding ? 'holding' : ''}`}
disabled={busy}
onPointerDown={() => setHolding(true)}
onPointerUp={async () => {
setHolding(false);
setBusy(true);
// ASR 未接:松开即结束录音占位,转写文本用占位字符串落事件
await onSubmit('(语音占位转写)');
setBusy(false);
}}
>
{holding ? '松开结束 · 讲述中…' : '按住说话'}
</button>
<button className="secondary" disabled={busy} onClick={onSkip}>
保持沉默(也是合法选项)
</button>
<p className="hint">讲述后档案馆将就缺失的细节追问至多 {node.followUp.maxQuestions} 句(MVP 未接,占位)</p>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react';
import type { EventStore, StoryEvent } from '../../engine/eventStore';
import type { JevClient } from '../../services/jev';
import { BADGE_MAP, PROFILE_DIMENSIONS } from './badges';
import { ShareCard } from './ShareCard';
export interface DimensionScore {
id: string;
label: string;
score: number;
max: number;
confidence: number;
}
interface Props {
store: EventStore;
jev: JevClient;
onReplay: () => void;
}
/** 把 append-only 事件轨迹汇总为喂给 Jev 的 state 文本 */
function buildState(events: StoryEvent[]): string {
const lines = events.map((e) => {
if (e.kind === 'choice') {
return `[${e.pointId}] 选择「${e.optionLabel}」(维度: ${e.dimensions.join('/') || '字段采集'})`;
}
const t = e.transcript === null ? '(沉默)' : `(讲述: ${e.transcript})`;
return `[${e.pointId}] 讲述${t} 情绪签名: ${e.emotionSignature ?? '未知'}`;
});
return `用户在互动剧情《苍梧疑冢》第一幕中的行为轨迹(按时间排序,含初刷与重玩轨迹):\n${lines.join('\n')}`;
}
/** Act 1 结束后的画像报告页:Jev 一次调用并行问 7 个维度 score */
export function ReportPage({ store, jev, onReplay }: Props) {
const [scores, setScores] = useState<DimensionScore[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const events = await store.list();
const state = buildState(events);
// 7 维 score 问题,criteria 为 1-10 级有序数组
const criteria = Array.from({ length: 10 }, (_, i) => String(i + 1));
const questions = Object.fromEntries(
PROFILE_DIMENSIONS.map((d) => [
d.id,
{
type: 'score' as const,
description: `根据用户行为轨迹,评估其「${d.label}」维度水平:${d.description}`,
criteria,
},
]),
);
try {
const answers = await jev.ask(state, questions);
if (cancelled) return;
setScores(
PROFILE_DIMENSIONS.map((d) => {
const a = answers[d.id];
return a?.type === 'score'
? { id: d.id, label: d.label, score: a.score, max: a.max, confidence: a.confidence }
: { id: d.id, label: d.label, score: 0, max: 10, confidence: 0 };
}),
);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
}
})();
return () => {
cancelled = true;
};
}, [store, jev]);
if (error) {
return (
<div className="report">
<h1>画像报告生成失败</h1>
<p>{error}</p>
<button className="primary" onClick={onReplay}>
回到剧情
</button>
</div>
);
}
if (!scores) {
return (
<div className="report">
<h1>档案馆正在整理你的记忆……</h1>
<p>Jev 正在评估 7 个维度。</p>
</div>
);
}
// 勋章 = 置信度最高的维度
const top = [...scores].sort((a, b) => b.confidence - a.confidence)[0];
const badge = BADGE_MAP[top.id];
return (
<div className="report">
<h1>人格画像报告 · Act 1</h1>
<section className="badge-banner">
<div className="badge-medal">勋</div>
<div>
<h2>{badge.title}</h2>
<p>{badge.flavor}</p>
<p className="badge-note">
(取自实测置信度最高的维度:{top.label},置信度 {Math.round(top.confidence * 100)}%)
</p>
</div>
</section>
<section className="dimension-list">
{scores.map((s) => (
<div key={s.id} className="dimension-row">
<span className="dimension-label">{s.label}</span>
<div className="dimension-bar">
<div className="dimension-fill" style={{ width: `${(s.score / s.max) * 100}%` }} />
</div>
<span className="dimension-value">
{s.score}/{s.max} · 置信度 {Math.round(s.confidence * 100)}%
</span>
</div>
))}
</section>
<ShareCard badgeDimension={top.id} scores={scores} />
<button className="secondary" onClick={onReplay}>
重玩 Act 1(新轨迹,历史保留)
</button>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useRef } from 'react';
import { BADGE_MAP, PROFILE_DIMENSIONS } from './badges';
import type { DimensionScore } from './ReportPage';
interface Props {
badgeDimension: string;
scores: DimensionScore[];
}
/** 分享卡:canvas 生成 PNG 下载(勋章 + 7 维分数) */
export function ShareCard({ badgeDimension, scores }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
draw();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scores, badgeDimension]);
function draw(): HTMLCanvasElement {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d')!;
const W = 640;
const H = 960;
canvas.width = W;
canvas.height = H;
const badge = BADGE_MAP[badgeDimension] ?? { title: '档案采集员', flavor: '' };
const dim = PROFILE_DIMENSIONS.find((d) => d.id === badgeDimension);
// 背景渐变(美术占位)
const bg = ctx.createLinearGradient(0, 0, W, H);
bg.addColorStop(0, '#1a1626');
bg.addColorStop(1, '#3a2a4a');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
ctx.fillStyle = '#c9b48a';
ctx.font = '28px serif';
ctx.fillText('苍梧疑冢 · 人格画像', W / 2, 80);
// 勋章圆
ctx.beginPath();
ctx.arc(W / 2, 220, 70, 0, Math.PI * 2);
ctx.fillStyle = '#d4a96a';
ctx.fill();
ctx.fillStyle = '#1a1626';
ctx.font = 'bold 44px serif';
ctx.fillText('勋', W / 2, 236);
ctx.fillStyle = '#f0e6d2';
ctx.font = 'bold 40px serif';
ctx.fillText(badge.title, W / 2, 360);
ctx.fillStyle = '#b0a490';
ctx.font = '22px serif';
ctx.fillText(badge.flavor, W / 2, 400);
if (dim) {
ctx.font = '20px serif';
ctx.fillText(`—— 实测维度:${dim.label} ——`, W / 2, 440);
}
// 7 维分数条
ctx.textAlign = 'left';
scores.forEach((s, i) => {
const y = 510 + i * 56;
ctx.fillStyle = '#d8ccb8';
ctx.font = '22px serif';
ctx.fillText(s.label, 80, y);
ctx.fillStyle = '#4a3f5a';
ctx.fillRect(260, y - 16, 240, 18);
ctx.fillStyle = '#d4a96a';
ctx.fillRect(260, y - 16, (240 * s.score) / s.max, 18);
ctx.fillStyle = '#d8ccb8';
ctx.fillText(`${s.score}`, 520, y);
});
ctx.textAlign = 'center';
ctx.fillStyle = '#8a7f6a';
ctx.font = '18px serif';
ctx.fillText('每个选择都是实测,没有自报。', W / 2, H - 40);
return canvas;
}
function download() {
const canvas = draw();
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = '苍梧疑冢-勋章分享卡.png';
a.click();
}
return (
<div className="share-card">
<canvas ref={canvasRef} className="share-canvas" width={640} height={960} />
<button className="primary" onClick={download}>
下载分享卡 PNG
</button>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { DimensionTag } from '../../engine/types';
/** 画像报告的 7 个实测维度(CONTEXT.md 实测框架) */
export const PROFILE_DIMENSIONS: { id: DimensionTag; label: string; description: string }[] = [
{ id: 'attachment', label: '依恋类型', description: '你在亲密与分离面前的姿态' },
{ id: 'conflict_style', label: '冲突处理风格', description: '分歧当前,你先做什么' },
{ id: 'values', label: '价值观与人生优先级', description: '两体面或两难堪之间,你让什么先走' },
{ id: 'emotional_stability', label: '情绪稳定性', description: '突发与悔恨面前的波形' },
{ id: 'risk', label: '风险偏好', description: '稳妥落点与精确落点之间的你' },
{ id: 'culture', label: '文化底蕴', description: '碑文、星象与礼制,实测而非自报' },
{ id: 'mate_preference', label: '择偶偏好', description: '你信任谁、把后背交给谁' },
];
/** 勋章映射:最高置信度维度 → 称号(写死的 MVP 映射表) */
export const BADGE_MAP: Record<string, { title: string; flavor: string }> = {
attachment: { title: '羁绊收藏家', flavor: '你把每一次告别都好好收着。' },
conflict_style: { title: '风暴中的舵手', flavor: '分歧越大,你的航线越清。' },
values: { title: '价值守门人', flavor: '两体面之间,你知道门在哪。' },
emotional_stability: { title: '不动如山', flavor: '塌方将至,你先站稳。' },
risk: { title: '深渊纵跃者', flavor: '别人看见风险,你看见落点。' },
culture: { title: '碑文破译者', flavor: '千年的缺字,你替它补上。' },
mate_preference: { title: '心灵捕手', flavor: '你交付信任的方式,藏着你要找的人。' },
};
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** TypeSafe AI System One (Jev) API key;缺失时自动降级 MockJevClient */
readonly VITE_TYPESAFE_API_KEY?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})