feat: MVP 工程脚手架——剧情引擎、Act 1 三章内容、Jev 服务层、画像报告与分享卡、Tauri 壳
@@ -0,0 +1,3 @@
|
|||||||
|
# TypeSafe AI System One (Jev) API key。
|
||||||
|
# 复制本文件为 .env 并填入真实 key;缺失时前端自动降级为 MockJevClient(确定性伪随机返分)。
|
||||||
|
VITE_TYPESAFE_API_KEY=
|
||||||
@@ -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
|
||||||
@@ -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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 节的低置信度维度插支线属于后续章节工作。
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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、实时生成(全部走章节间预生成)。
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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全部校验通过');
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "enables the default permissions",
|
||||||
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -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");
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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「已知缺口」。
|
||||||
|
*/
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: '你交付信任的方式,藏着你要找的人。' },
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||