Compare commits
6 Commits
7b68504b1e
...
1d2b4b3e1e
| Author | SHA1 | Date |
|---|---|---|
|
|
1d2b4b3e1e | |
|
|
301f499494 | |
|
|
8213092bb6 | |
|
|
4f9d295bd5 | |
|
|
62aff91a86 | |
|
|
eecf429a20 |
|
|
@ -0,0 +1,432 @@
|
||||||
|
# CSV 编写说明
|
||||||
|
|
||||||
|
TTRPG Tools 使用带有 YAML Front Matter 的 CSV 格式来定义卡牌/表格数据。这种格式支持在 CSV 文件头部定义元数据、字段配置和共享属性。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```csv
|
||||||
|
---
|
||||||
|
# YAML Front Matter(可选)
|
||||||
|
fields:
|
||||||
|
- name: 字段名
|
||||||
|
description: 字段描述
|
||||||
|
deck:
|
||||||
|
size: 54x86
|
||||||
|
grid: 5x8
|
||||||
|
shared_prop: 共享属性值
|
||||||
|
---
|
||||||
|
# CSV 数据
|
||||||
|
label,body,field1,field2
|
||||||
|
1,内容,值 1,值 2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## YAML Front Matter
|
||||||
|
|
||||||
|
Front Matter 是可选的,位于文件开头,使用 `---` 包裹的 YAML 格式内容。
|
||||||
|
|
||||||
|
### 基本结构
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
fields:
|
||||||
|
- name: name
|
||||||
|
description: 卡牌名称
|
||||||
|
- name: effect
|
||||||
|
description: 效果描述
|
||||||
|
examples: ["造成 5d6 伤害", "恢复 2d4 生命值"]
|
||||||
|
deck:
|
||||||
|
size: 63x88
|
||||||
|
grid: 5x5
|
||||||
|
bleed: 1
|
||||||
|
padding: 2
|
||||||
|
custom_prop: 自定义属性
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
### Front Matter 属性
|
||||||
|
|
||||||
|
#### `fields` - 字段定义
|
||||||
|
|
||||||
|
定义 CSV 中各列的含义和配置。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
fields:
|
||||||
|
- name: name # 字段名称(英文,用于 CSV 列名)
|
||||||
|
description: 卡牌名称 # 字段描述
|
||||||
|
examples: # 示例值列表(可选)
|
||||||
|
- 火球术卷轴
|
||||||
|
- 治疗药水
|
||||||
|
dataType: word # 数据类型(可选):word/paragraph/number/symbol/symbol_list
|
||||||
|
function: identify # 字段功能(可选):identify/compare/flavor/rule
|
||||||
|
```
|
||||||
|
|
||||||
|
**数据类型说明:**
|
||||||
|
|
||||||
|
| 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `word` | 词语 - 短文本 | "火球术"、"战士"、"传说" |
|
||||||
|
| `paragraph` | 文本段落 - 长描述 | "造成 5d6 点火焰伤害,范围内的所有生物进行敏捷豁免" |
|
||||||
|
| `number` | 数字 - 可比较的数值 | 3、5、10 |
|
||||||
|
| `symbol` | 单一符号 - 图标/标记 | 🔥、⚔️、🛡️ |
|
||||||
|
| `symbol_list` | 符号列表 - 多个图标/标记 | 🔥🔥🔥、⚔️🛡️💫 |
|
||||||
|
|
||||||
|
**字段功能说明:**
|
||||||
|
|
||||||
|
| 功能 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `identify` | 辨识 - 用于识别卡牌身份 | 名称、编号、唯一标识 |
|
||||||
|
| `compare` | 比较 - 用于游戏机制中的数值比较 | 费用、攻击力、防御力 |
|
||||||
|
| `flavor` | 游戏风味描述 - 提供背景故事/氛围 | 背景故事、引言、lore |
|
||||||
|
| `rule` | 规则确认 - 明确游戏规则相关的信息 | 效果文本、触发条件、限制 |
|
||||||
|
|
||||||
|
#### `deck` - 卡牌显示配置
|
||||||
|
|
||||||
|
用于 `:md-deck` 组件的默认配置。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
deck:
|
||||||
|
size: 63x88 # 卡牌尺寸(mm),格式 "宽 x 高"
|
||||||
|
grid: 5x5 # 网格布局,格式 "列 x 行"
|
||||||
|
bleed: 1 # 出血边距(mm)
|
||||||
|
padding: 2 # 内边距(mm)
|
||||||
|
shape: rectangle # 卡牌形状:rectangle/circle/hex/diamond
|
||||||
|
layers: "name:1-2,14" # 图层配置(可选)
|
||||||
|
back_layers: "" # 背面图层配置(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
图层配置语法遵循`layer:1,1-5,8f8s`格式:
|
||||||
|
- `layer`:图层名称,多个图层用空格分隔
|
||||||
|
- `1,1-5,8`:网格范围,覆盖卡牌排版网格中的指定区域。
|
||||||
|
- `f8`:字体大小,使用8mm字体。可选。
|
||||||
|
- `s`:图层朝向,有nwse东西南北四种方向,描述文字上侧的朝向,默认为n北方。
|
||||||
|
|
||||||
|
#### 自定义属性
|
||||||
|
|
||||||
|
可以在 Front Matter 中定义任意自定义属性,这些属性会被自动应用到所有卡牌行。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
game_system: 龙与地下城 5e
|
||||||
|
author: DM_Name
|
||||||
|
version: 1.0
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSV 数据部分
|
||||||
|
|
||||||
|
### 基本格式
|
||||||
|
|
||||||
|
```csv
|
||||||
|
label,body,field1,field2
|
||||||
|
1,内容,值 1,值 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 必需列
|
||||||
|
|
||||||
|
#### `label`
|
||||||
|
|
||||||
|
每行的唯一标识,用于查找和修改卡牌。
|
||||||
|
|
||||||
|
- 可以是数字:`1`, `2`, `3`
|
||||||
|
- 可以是文本:`card_001`, `npc_villager`
|
||||||
|
- 支持分组前缀:`forest_1`, `dungeon_1`
|
||||||
|
|
||||||
|
#### `body`
|
||||||
|
|
||||||
|
卡牌的主要内容,支持 Markdown 格式。
|
||||||
|
|
||||||
|
```csv
|
||||||
|
label,body
|
||||||
|
1,"### 标题
|
||||||
|
|
||||||
|
**加粗文本**
|
||||||
|
|
||||||
|
- 列表项 1
|
||||||
|
- 列表项 2"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 可选列
|
||||||
|
|
||||||
|
除 `label` 和 `body` 外,可以定义任意自定义列,列名在 Front Matter 的 `fields` 中定义。
|
||||||
|
|
||||||
|
```csv
|
||||||
|
label,body,name,type,cost,effect
|
||||||
|
1,完整内容,火球术,法术,3,造成 5d6 火焰伤害
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 变量语法
|
||||||
|
|
||||||
|
在 `body` 列或 Front Matter 中,可以使用 `{{prop}}` 语法引用其他列的值或 Front Matter 中的属性。
|
||||||
|
|
||||||
|
### 引用同行其他列
|
||||||
|
|
||||||
|
```csv
|
||||||
|
label,body,adj,noun
|
||||||
|
1,"**{{adj}}** 的{{noun}}",高大,战士
|
||||||
|
2,"{{adj}}的{{noun}}",矮小,法师
|
||||||
|
```
|
||||||
|
|
||||||
|
渲染后:
|
||||||
|
- 第 1 行:**高大** 的战士
|
||||||
|
- 第 2 行:矮小的法师
|
||||||
|
|
||||||
|
### 引用 Front Matter 属性
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
game_system: D&D 5e
|
||||||
|
author: DM_Name
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv
|
||||||
|
label,body
|
||||||
|
1,"这是一个{{game_system}}冒险,由{{author}}创建"
|
||||||
|
```
|
||||||
|
|
||||||
|
渲染后:这是一个 D&D 5e 冒险,由 DM_Name 创建
|
||||||
|
|
||||||
|
### 随机引用(remix 模式)
|
||||||
|
|
||||||
|
在 `:md-table` 组件中,可以使用 `remix` 属性启用随机引用:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
:md-table[./data.csv]{remix=true}
|
||||||
|
```
|
||||||
|
|
||||||
|
此时 `{{prop}}` 会从所有行中随机选择一行的值,而不是使用当前行的值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 分组支持
|
||||||
|
|
||||||
|
可以使用额外的列来对数据进行分组。
|
||||||
|
|
||||||
|
```csv
|
||||||
|
group,label,body
|
||||||
|
森林小径,1,狼群袭击
|
||||||
|
森林小径,2,荆棘陷阱
|
||||||
|
森林小径,3,迷路
|
||||||
|
塔楼楼梯,1,台阶坍塌
|
||||||
|
塔楼楼梯,2,落石
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `:md-table` 组件中,可以使用 `group` 属性来筛选显示特定组的数据:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
:md-table[./encounters.csv]{group="森林小径"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 特殊字符处理
|
||||||
|
|
||||||
|
### 包含逗号的值
|
||||||
|
|
||||||
|
使用双引号包裹:
|
||||||
|
|
||||||
|
```csv
|
||||||
|
label,body
|
||||||
|
1,"Hello, world"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 包含双引号的值
|
||||||
|
|
||||||
|
使用两个双引号转义:
|
||||||
|
|
||||||
|
```csv
|
||||||
|
label,body
|
||||||
|
1,"他说:""你好"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 包含换行的值
|
||||||
|
|
||||||
|
使用双引号包裹,直接写入换行:
|
||||||
|
|
||||||
|
```csv
|
||||||
|
label,body
|
||||||
|
1,"### 标题
|
||||||
|
|
||||||
|
这是第一段
|
||||||
|
|
||||||
|
这是第二段"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 包含 `#` 注释
|
||||||
|
|
||||||
|
CSV 解析器会将 `#` 开头的行视为注释,但如果 `#` 在双引号内则正常处理:
|
||||||
|
|
||||||
|
```csv
|
||||||
|
label,body
|
||||||
|
1,"# 这不是注释"
|
||||||
|
# 这是真正的注释
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
### 示例 1:魔法物品卡牌组
|
||||||
|
|
||||||
|
```csv
|
||||||
|
---
|
||||||
|
fields:
|
||||||
|
- name: name
|
||||||
|
description: 物品名称
|
||||||
|
dataType: word
|
||||||
|
function: identify
|
||||||
|
- name: type
|
||||||
|
description: 物品类型
|
||||||
|
dataType: word
|
||||||
|
function: identify
|
||||||
|
examples: [武器,防具,饰品,消耗品]
|
||||||
|
- name: rarity
|
||||||
|
description: 稀有度
|
||||||
|
dataType: word
|
||||||
|
function: compare
|
||||||
|
examples: [普通,稀有,史诗,传说]
|
||||||
|
- name: cost
|
||||||
|
description: 价格
|
||||||
|
dataType: number
|
||||||
|
function: compare
|
||||||
|
- name: effect
|
||||||
|
description: 效果描述
|
||||||
|
dataType: paragraph
|
||||||
|
function: rule
|
||||||
|
deck:
|
||||||
|
size: 63x88
|
||||||
|
grid: 5x5
|
||||||
|
shape: rectangle
|
||||||
|
game_system: D&D 5e
|
||||||
|
---
|
||||||
|
label,body,name,type,rarity,cost,effect
|
||||||
|
1,"### 火球术卷轴
|
||||||
|
|
||||||
|
一张泛黄的羊皮纸,上面绘有火焰符文。",火球术卷轴,消耗品,稀有,150,"投掷后对 20 尺内所有生物造成 8d6 火焰伤害(敏捷豁免减半)"
|
||||||
|
2,"### 治疗药水
|
||||||
|
|
||||||
|
红色液体在玻璃瓶中翻滚,散发着温暖的光芒。",治疗药水,消耗品,普通,50,"饮用后恢复 2d4+2 点生命值"
|
||||||
|
3,"### +1 长剑
|
||||||
|
|
||||||
|
剑刃闪烁着微光,握柄包裹着精致的皮革。",+1 长剑,武器,稀有,300,"攻击和伤害检定 +1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 2:随机遭遇表
|
||||||
|
|
||||||
|
```csv
|
||||||
|
---
|
||||||
|
fields:
|
||||||
|
- name: group
|
||||||
|
description: 遭遇地点分组
|
||||||
|
- name: title
|
||||||
|
description: 遭遇标题
|
||||||
|
- name: type
|
||||||
|
description: 遭遇类型
|
||||||
|
deck:
|
||||||
|
size: 54x86
|
||||||
|
grid: 4x6
|
||||||
|
---
|
||||||
|
group,label,body,title,type
|
||||||
|
森林小径,1,"### 狼群袭击
|
||||||
|
|
||||||
|
三只灰狼从灌木丛中扑出。
|
||||||
|
|
||||||
|
**交互**:战斗(3 只巨狼)或逃脱(敏捷检定 3/2)
|
||||||
|
|
||||||
|
**风险**:失败受到 d8 撕咬伤害",狼群袭击,战斗
|
||||||
|
森林小径,2,"### 荆棘陷阱
|
||||||
|
|
||||||
|
带刺藤蔓缠住你的双腿。
|
||||||
|
|
||||||
|
**交互**:挣脱(力量检定 3/2)
|
||||||
|
|
||||||
|
**风险**:失败受到 1 点体质损伤",荆棘陷阱,陷阱
|
||||||
|
森林小径,3,"### 迷路
|
||||||
|
|
||||||
|
浓雾弥漫,你失去了方向。
|
||||||
|
|
||||||
|
**交互**:寻找路径(感知检定 3/2)
|
||||||
|
|
||||||
|
**风险**:失败受到 1 点感知损伤",迷路,事件
|
||||||
|
塔楼楼梯,1,"### 台阶坍塌
|
||||||
|
|
||||||
|
脚下的石阶突然碎裂。
|
||||||
|
|
||||||
|
**交互**:保持平衡(敏捷检定 3/1)
|
||||||
|
|
||||||
|
**风险**:失败受到 1 点敏捷损伤",台阶坍塌,陷阱
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 3:NPC 名录
|
||||||
|
|
||||||
|
```csv
|
||||||
|
---
|
||||||
|
fields:
|
||||||
|
- name: name
|
||||||
|
description: NPC 名称
|
||||||
|
- name: role
|
||||||
|
description: 角色定位
|
||||||
|
- name: location
|
||||||
|
description: 出现地点
|
||||||
|
deck:
|
||||||
|
size: 54x86
|
||||||
|
grid: 4x6
|
||||||
|
---
|
||||||
|
label,body,name,role,location
|
||||||
|
1,"### 老妇人玛拉
|
||||||
|
|
||||||
|
**类型**:信息提供者/商人
|
||||||
|
|
||||||
|
**位置**:村庄入口
|
||||||
|
|
||||||
|
**出售**:草药、治疗药水
|
||||||
|
|
||||||
|
**情报**:知道关于森林的警告",老妇人玛拉,商人,村庄入口
|
||||||
|
2,"### 护卫队长塞里克
|
||||||
|
|
||||||
|
**类型**:任务发布者
|
||||||
|
|
||||||
|
**位置**:酒馆二楼
|
||||||
|
|
||||||
|
**任务**:调查废弃庄园
|
||||||
|
|
||||||
|
**秘密**:知道血契的真相",塞里克,任务发布者,酒馆
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与组件集成
|
||||||
|
|
||||||
|
### `:md-deck` 组件
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
:md-deck[./magic-items.csv]{size="63x88" grid="5x5" roll=true}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `roll=true`:添加随机抽卡按钮
|
||||||
|
|
||||||
|
### `:md-table` 组件
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
:md-table[./encounters.csv]{group="森林小径" remix=true}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `group`:筛选特定组的数据
|
||||||
|
- `remix=true`:启用随机引用模式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **使用有意义的 label**:便于后续查找和修改
|
||||||
|
2. **在 Front Matter 中定义所有字段**:便于工具理解和处理
|
||||||
|
3. **使用 `{{prop}}` 变量**:减少重复内容,支持动态组合
|
||||||
|
4. **合理使用分组**:便于筛选和管理大量数据
|
||||||
|
5. **保持 body 的 Markdown 格式一致**:确保渲染效果统一
|
||||||
|
6. **为字段添加 `examples`**:帮助 AI 工具生成内容时参考
|
||||||
464
docs/mcp.md
464
docs/mcp.md
|
|
@ -1,6 +1,6 @@
|
||||||
# MCP 服务器使用说明
|
# MCP 服务器使用说明
|
||||||
|
|
||||||
TTRPG Tools 提供了一个 MCP (Model Context Protocol) 服务器,用于与 AI 助手集成,自动化生成卡牌内容。
|
TTRPG Tools 提供了一个 MCP (Model Context Protocol) 服务器,用于与 AI 助手集成,自动化生成和管理卡牌内容。
|
||||||
|
|
||||||
## 命令结构
|
## 命令结构
|
||||||
|
|
||||||
|
|
@ -79,11 +79,15 @@ ttrpg mcp generate-card-deck \
|
||||||
--grid "3x4"
|
--grid "3x4"
|
||||||
```
|
```
|
||||||
|
|
||||||
## MCP 工具:generate_card_deck
|
## MCP 工具
|
||||||
|
|
||||||
通过 MCP 协议,AI 助手可以调用 `generate_card_deck` 工具生成卡牌组。
|
通过 MCP 协议,AI 助手可以调用以下工具:
|
||||||
|
|
||||||
### 工具参数
|
### 快捷工具
|
||||||
|
|
||||||
|
#### `generate_card_deck` - 一站式生成卡牌组
|
||||||
|
|
||||||
|
快速生成完整的卡牌组,包括 Markdown 文件、CSV 数据文件和组件配置。
|
||||||
|
|
||||||
| 参数 | 类型 | 必需 | 说明 |
|
| 参数 | 类型 | 必需 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
|
|
@ -94,8 +98,41 @@ ttrpg mcp generate-card-deck \
|
||||||
| `deck_config` | object | ✗ | md-deck 组件配置 |
|
| `deck_config` | object | ✗ | md-deck 组件配置 |
|
||||||
| `description` | string | ✗ | 卡牌组描述 |
|
| `description` | string | ✗ | 卡牌组描述 |
|
||||||
|
|
||||||
### card_template 结构
|
### 核心工具
|
||||||
|
|
||||||
|
#### `deck_frontmatter_read` - 读取 CSV frontmatter
|
||||||
|
|
||||||
|
读取 CSV 文件的 frontmatter(包含模板定义和 deck 配置)。
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必需 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `csv_file` | string | ✓ | CSV 文件路径 |
|
||||||
|
|
||||||
|
**返回示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
{ "name": "name", "description": "卡牌名称" },
|
||||||
|
{ "name": "type", "description": "卡牌类型" }
|
||||||
|
],
|
||||||
|
"deck": {
|
||||||
|
"size": "54x86",
|
||||||
|
"grid": "5x8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `deck_frontmatter_write` - 写入 CSV frontmatter
|
||||||
|
|
||||||
|
写入或更新 CSV 文件的 frontmatter(模板定义和 deck 配置)。
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必需 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `csv_file` | string | ✓ | CSV 文件路径 |
|
||||||
|
| `frontmatter` | object | ✓ | 要写入的 frontmatter 数据 |
|
||||||
|
| `merge` | boolean | ✗ | 是否合并现有 frontmatter(默认 true) |
|
||||||
|
|
||||||
|
**frontmatter 结构:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"fields": [
|
"fields": [
|
||||||
|
|
@ -105,84 +142,355 @@ ttrpg mcp generate-card-deck \
|
||||||
"examples": ["示例值 1", "示例值 2"]
|
"examples": ["示例值 1", "示例值 2"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"examples": [
|
"deck": {
|
||||||
{
|
"size": "54x86",
|
||||||
"字段名称": "值 1",
|
"grid": "5x8",
|
||||||
"字段名称 2": "值 2"
|
"bleed": 1,
|
||||||
}
|
"padding": 2,
|
||||||
]
|
"shape": "rectangle",
|
||||||
}
|
"layers": "name:1,2-3,12",
|
||||||
```
|
"back_layers": "back:1,2-8,12"
|
||||||
|
|
||||||
### deck_config 结构
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"size": "54x86",
|
|
||||||
"grid": "5x8",
|
|
||||||
"bleed": 1,
|
|
||||||
"padding": 2,
|
|
||||||
"shape": "rectangle",
|
|
||||||
"layers": "name:1,2-3,12 desc:1,4-8,10",
|
|
||||||
"back_layers": "back:1,2-8,12"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 使用示例(AI 助手)
|
|
||||||
|
|
||||||
**用户请求:**
|
|
||||||
> 帮我生成一个魔法物品卡牌组,包含 15 张卡牌,字段有名称、稀有度、效果描述
|
|
||||||
|
|
||||||
**AI 助手调用工具:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"deck_name": "魔法物品",
|
|
||||||
"output_dir": "./content",
|
|
||||||
"card_count": 15,
|
|
||||||
"card_template": {
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "name",
|
|
||||||
"description": "物品名称",
|
|
||||||
"examples": ["火球术卷轴", "治疗药水", "隐形斗篷"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rarity",
|
|
||||||
"description": "稀有度",
|
|
||||||
"examples": ["稀有", "普通", "珍贵"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "effect",
|
|
||||||
"description": "效果描述",
|
|
||||||
"examples": ["造成 5d6 火焰伤害", "恢复 2d4+2 生命值", "隐身 1 小时"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**工具返回:**
|
#### `deck_card_crud` - 卡牌 CRUD 操作
|
||||||
- 生成的 Markdown 文件路径
|
|
||||||
- 生成的 CSV 文件路径
|
|
||||||
- `:md-deck` 组件代码
|
|
||||||
|
|
||||||
## 输出文件
|
卡牌的创建、读取、更新、删除操作,支持批量操作。
|
||||||
|
|
||||||
运行工具后会生成:
|
| 参数 | 类型 | 必需 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `csv_file` | string | ✓ | CSV 文件路径 |
|
||||||
|
| `action` | string | ✓ | 操作类型:`create` \| `read` \| `update` \| `delete` |
|
||||||
|
| `cards` | object\|array | ✗ | 卡牌数据(单张或数组) |
|
||||||
|
| `label` | string\|array | ✗ | 要操作的卡牌 label(用于 read/update/delete) |
|
||||||
|
|
||||||
1. **Markdown 文件** (`{deck_name}.md`)
|
**卡牌数据结构:**
|
||||||
- 包含卡牌组标题和描述
|
```json
|
||||||
- 嵌入 `:md-deck` 组件代码
|
{
|
||||||
- 使用说明
|
"label": "1",
|
||||||
|
"name": "火球术卷轴",
|
||||||
|
"type": "法术",
|
||||||
|
"cost": "3",
|
||||||
|
"description": "造成 5d6 火焰伤害"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
2. **CSV 文件** (`{deck_name}.csv`)
|
**使用示例:**
|
||||||
- 包含所有卡牌数据
|
|
||||||
- 支持 `{{字段名}}` 变量语法
|
|
||||||
- 可使用 front matter 添加共享属性
|
|
||||||
|
|
||||||
3. **组件代码**
|
```json
|
||||||
- 可直接插入任何 Markdown 文件
|
// 创建单张卡牌
|
||||||
- 格式:`:md-deck[./xxx.csv]{size="54x86" grid="5x8" ...}`
|
{
|
||||||
|
"csv_file": "./content/magic-items.csv",
|
||||||
|
"action": "create",
|
||||||
|
"cards": {
|
||||||
|
"label": "1",
|
||||||
|
"name": "火球术卷轴",
|
||||||
|
"type": "法术",
|
||||||
|
"cost": "3",
|
||||||
|
"description": "造成 5d6 火焰伤害"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量创建卡牌
|
||||||
|
{
|
||||||
|
"csv_file": "./content/magic-items.csv",
|
||||||
|
"action": "create",
|
||||||
|
"cards": [
|
||||||
|
{ "name": "火球术卷轴", "type": "法术", "cost": "3" },
|
||||||
|
{ "name": "治疗药水", "type": "物品", "cost": "2" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取所有卡牌
|
||||||
|
{
|
||||||
|
"csv_file": "./content/magic-items.csv",
|
||||||
|
"action": "read"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取指定卡牌
|
||||||
|
{
|
||||||
|
"csv_file": "./content/magic-items.csv",
|
||||||
|
"action": "read",
|
||||||
|
"label": ["1", "2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新卡牌
|
||||||
|
{
|
||||||
|
"csv_file": "./content/magic-items.csv",
|
||||||
|
"action": "update",
|
||||||
|
"cards": { "label": "1", "cost": "4" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除卡牌
|
||||||
|
{
|
||||||
|
"csv_file": "./content/magic-items.csv",
|
||||||
|
"action": "delete",
|
||||||
|
"label": ["1", "2"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `deck_ensure_preview` - 确保 Markdown 预览文件存在
|
||||||
|
|
||||||
|
确保 CSV 对应的 Markdown 预览文件存在,如果不存在则创建。
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必需 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `csv_file` | string | ✓ | CSV 文件路径 |
|
||||||
|
| `md_file` | string | ✗ | Markdown 文件路径(可选,默认与 CSV 同名) |
|
||||||
|
| `title` | string | ✗ | 标题(可选,默认从 CSV 文件名推断) |
|
||||||
|
| `description` | string | ✗ | 描述(可选) |
|
||||||
|
|
||||||
|
## CSV 文件格式
|
||||||
|
|
||||||
|
CSV 文件使用 YAML frontmatter 定义模板和配置:
|
||||||
|
|
||||||
|
```csv
|
||||||
|
---
|
||||||
|
fields:
|
||||||
|
- name: name
|
||||||
|
description: 卡牌名称
|
||||||
|
- name: type
|
||||||
|
description: 卡牌类型
|
||||||
|
examples: [物品,法术,生物]
|
||||||
|
- name: cost
|
||||||
|
description: 费用
|
||||||
|
- name: description
|
||||||
|
description: 效果描述
|
||||||
|
deck:
|
||||||
|
size: 54x86
|
||||||
|
grid: 5x8
|
||||||
|
bleed: 1
|
||||||
|
padding: 2
|
||||||
|
---
|
||||||
|
label,name,type,cost,description
|
||||||
|
1,火球术卷轴,法术,3,造成 5d6 火焰伤害
|
||||||
|
2,治疗药水,物品,2,恢复 2d4+2 生命值
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontmatter 说明
|
||||||
|
|
||||||
|
- `fields`: 字段定义列表
|
||||||
|
- `name`: 字段名称(英文,用于 CSV 列名)
|
||||||
|
- `description`: 字段描述
|
||||||
|
- `examples`: 示例值列表(可选)
|
||||||
|
- `deck`: Deck 配置
|
||||||
|
- `size`: 卡牌尺寸,格式 "宽 x 高"(单位 mm)
|
||||||
|
- `grid`: 网格布局,格式 "列 x 行"
|
||||||
|
- `bleed`: 出血边距(mm)
|
||||||
|
- `padding`: 内边距(mm)
|
||||||
|
- `shape`: 卡牌形状(rectangle, circle, hex, diamond)
|
||||||
|
- `layers`: 正面图层配置
|
||||||
|
- `back_layers`: 背面图层配置
|
||||||
|
|
||||||
|
### CSV 数据说明
|
||||||
|
|
||||||
|
- `label`: 卡牌标签(唯一标识,用于查找和修改)
|
||||||
|
- 其他列:由 frontmatter 中的 `fields` 定义
|
||||||
|
- `body`: 卡牌 body 内容(可选,支持 `{{字段名}}` 语法)
|
||||||
|
|
||||||
|
## MCP Resources
|
||||||
|
|
||||||
|
MCP 服务器提供以下 Resources,包含 TTRPG Tools 的文档和参考材料:
|
||||||
|
|
||||||
|
### `ttrpg-docs://csv` - CSV 编写说明
|
||||||
|
|
||||||
|
**名称:** csv.md
|
||||||
|
**标题:** CSV 编写说明
|
||||||
|
**描述:** TTRPG Tools CSV 文件格式说明,包括 Front Matter、字段定义、变量语法等
|
||||||
|
|
||||||
|
**内容包含:**
|
||||||
|
- YAML Front Matter 结构(fields、deck、自定义属性)
|
||||||
|
- 数据类型(word、paragraph、number、symbol、symbol_list)
|
||||||
|
- 字段功能(identify、compare、flavor、rule)
|
||||||
|
- CSV 数据格式和特殊字符处理
|
||||||
|
- 变量语法(`{{prop}}`)
|
||||||
|
- 分组支持
|
||||||
|
- 完整示例(魔法物品、随机遭遇表、NPC 名录)
|
||||||
|
|
||||||
|
### `ttrpg-docs://markdown` - Markdown 编写说明
|
||||||
|
|
||||||
|
**名称:** markdown.md
|
||||||
|
**标题:** Markdown 编写说明
|
||||||
|
**描述:** TTRPG Tools Markdown 扩展语法和组件用法说明
|
||||||
|
|
||||||
|
**内容包含:**
|
||||||
|
- 基础语法(GFM、marked-alert、marked-directive)
|
||||||
|
- 指令语法格式
|
||||||
|
- 图标语法(`:[icon-name]`)
|
||||||
|
- 组件库:
|
||||||
|
- `:md-dice` - 骰子组件
|
||||||
|
- `:md-link` - 链接组件
|
||||||
|
- `:md-bg` - 背景组件
|
||||||
|
- `:md-pins` - 标记组件
|
||||||
|
- `:md-table` - 表格组件
|
||||||
|
- `:md-deck` - 卡牌组件
|
||||||
|
- `:md-yarn-spinner` - 叙事线组件
|
||||||
|
- `:md-token` - 代币组件
|
||||||
|
- `:md-commander` - 命令追踪器
|
||||||
|
- YAML 标签
|
||||||
|
- Mermaid 图表
|
||||||
|
- 警告/提示块
|
||||||
|
- 文件引用规则
|
||||||
|
- 样式定制
|
||||||
|
|
||||||
|
### 使用示例
|
||||||
|
|
||||||
|
**列出所有 Resources:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "resources/list"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**读取 Resource:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 2,
|
||||||
|
"method": "resources/read",
|
||||||
|
"params": {
|
||||||
|
"uri": "ttrpg-docs://csv"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP Prompts
|
||||||
|
|
||||||
|
MCP 服务器提供以下 Prompts,用于引导用户完成卡牌设计工作流:
|
||||||
|
|
||||||
|
### `design-card-game` - 设计卡牌游戏
|
||||||
|
|
||||||
|
引导用户设计新的卡牌游戏系统,定义卡牌模板和字段结构。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必需 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `deck_name` | string | ✗ | 卡牌组名称 |
|
||||||
|
| `output_dir` | string | ✗ | 输出目录(相对路径) |
|
||||||
|
| `game_theme` | string | ✗ | 游戏主题/类型(如:奇幻、科幻、恐怖等) |
|
||||||
|
|
||||||
|
**使用示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "design-card-game",
|
||||||
|
"arguments": {
|
||||||
|
"deck_name": "魔法物品",
|
||||||
|
"output_dir": "./content",
|
||||||
|
"game_theme": "奇幻"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回内容:**
|
||||||
|
|
||||||
|
Prompt 会返回一个对话式的引导流程,帮助 AI 助手了解:
|
||||||
|
1. 卡牌类型(角色卡、物品卡、法术卡等)
|
||||||
|
2. 核心机制(费用系统、稀有度、阵营等)
|
||||||
|
3. 卡牌信息字段
|
||||||
|
|
||||||
|
并提供常见配置的示例参考。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `populate-deck` - 填充卡牌内容
|
||||||
|
|
||||||
|
为已有的卡牌组生成和填充卡牌内容。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必需 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `csv_file` | string | ✗ | CSV 文件路径 |
|
||||||
|
| `card_count` | number | ✗ | 要生成的卡牌数量 |
|
||||||
|
| `theme` | string | ✗ | 卡牌主题/描述 |
|
||||||
|
|
||||||
|
**使用示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "populate-deck",
|
||||||
|
"arguments": {
|
||||||
|
"csv_file": "./content/magic-items.csv",
|
||||||
|
"card_count": 20,
|
||||||
|
"theme": "火焰主题法术"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回内容:**
|
||||||
|
|
||||||
|
Prompt 会引导 AI 助手:
|
||||||
|
1. 读取现有卡牌模板结构
|
||||||
|
2. 选择生成方式(随机/主题化/手动/扩展现有)
|
||||||
|
3. 生成符合主题的卡牌内容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `setup-deck-display` - 配置卡牌显示
|
||||||
|
|
||||||
|
引导用户配置卡牌的显示参数(尺寸、布局、样式等)。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必需 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `csv_file` | string | ✗ | CSV 文件路径 |
|
||||||
|
| `usage` | string | ✗ | 卡牌用途(如:桌面游戏、印刷、在线预览等) |
|
||||||
|
|
||||||
|
**使用示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "setup-deck-display",
|
||||||
|
"arguments": {
|
||||||
|
"csv_file": "./content/magic-items.csv",
|
||||||
|
"usage": "桌面游戏"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回内容:**
|
||||||
|
|
||||||
|
Prompt 会引导 AI 助手配置:
|
||||||
|
1. 卡牌尺寸(桥牌尺寸、标准尺寸、塔罗尺寸等)
|
||||||
|
2. 网格布局(每张 A4 纸的排列)
|
||||||
|
3. 出血边距、内边距
|
||||||
|
4. 卡牌形状
|
||||||
|
5. 图层配置(自动排版文字)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作流示例
|
||||||
|
|
||||||
|
### 创建新卡牌组
|
||||||
|
|
||||||
|
1. **设计模板**:调用 `design-card-game` Prompt 引导设计字段结构
|
||||||
|
2. **定义模板**:调用 `deck_frontmatter_write` 创建 CSV 和 frontmatter
|
||||||
|
3. **创建预览**:调用 `deck_ensure_preview` 创建 Markdown 预览文件
|
||||||
|
4. **填充内容**:调用 `populate-deck` Prompt 引导生成卡牌内容
|
||||||
|
5. **添加卡牌**:调用 `deck_card_crud`(action=create)添加卡牌
|
||||||
|
6. **配置显示**:调用 `setup-deck-display` Prompt 配置显示参数
|
||||||
|
7. **保存配置**:调用 `deck_frontmatter_write` 更新 deck 配置
|
||||||
|
|
||||||
|
### 修改现有卡牌组
|
||||||
|
|
||||||
|
1. **读取模板**:调用 `deck_frontmatter_read` 获取当前配置
|
||||||
|
2. **读取卡牌**:调用 `deck_card_crud`(action=read)获取卡牌数据
|
||||||
|
3. **修改卡牌**:调用 `deck_card_crud`(action=update)更新卡牌
|
||||||
|
4. **更新预览**:调用 `deck_ensure_preview` 更新 Markdown 文件
|
||||||
|
|
||||||
|
### 快捷生成
|
||||||
|
|
||||||
|
直接调用 `generate_card_deck` 一站式生成完整卡牌组。
|
||||||
|
|
||||||
## 与 TTRPG Tools 集成
|
## 与 TTRPG Tools 集成
|
||||||
|
|
||||||
|
|
@ -213,7 +521,13 @@ src/cli/
|
||||||
│ ├── compile.ts
|
│ ├── compile.ts
|
||||||
│ └── mcp.ts # MCP 命令入口
|
│ └── mcp.ts # MCP 命令入口
|
||||||
├── tools/
|
├── tools/
|
||||||
│ └── generate-card-deck.ts # 卡牌生成工具
|
│ ├── frontmatter/
|
||||||
|
│ │ ├── read-frontmatter.ts
|
||||||
|
│ │ └── write-frontmatter.ts
|
||||||
|
│ ├── card/
|
||||||
|
│ │ └── card-crud.ts
|
||||||
|
│ ├── ensure-deck-preview.ts
|
||||||
|
│ └── generate-card-deck.ts
|
||||||
└── index.ts
|
└── index.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
|
"csv-stringify": "^6.7.0",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
"marked-alert": "^2.1.2",
|
"marked-alert": "^2.1.2",
|
||||||
|
|
@ -4828,6 +4829,12 @@
|
||||||
"integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==",
|
"integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/csv-stringify": {
|
||||||
|
"version": "6.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.7.0.tgz",
|
||||||
|
"integrity": "sha512-UdtziYp5HuTz7e5j8Nvq+a/3HQo+2/aJZ9xntNTpmRRIg/3YYqDVgiS9fvAhtNbnyfbv2ZBe0bqCHqzhE7FqWQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cytoscape": {
|
"node_modules/cytoscape": {
|
||||||
"version": "3.33.1",
|
"version": "3.33.1",
|
||||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
|
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
|
"csv-stringify": "^6.7.0",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
"marked-alert": "^2.1.2",
|
"marked-alert": "^2.1.2",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,29 @@
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { generateCardDeck, type GenerateCardDeckParams, type CardField } from '../tools/generate-card-deck.js';
|
import { generateCardDeck, type GenerateCardDeckParams, type CardField } from '../tools/generate-card-deck.js';
|
||||||
|
import { readFrontmatter, type ReadFrontmatterParams, type DeckFrontmatter } from '../tools/frontmatter/read-frontmatter.js';
|
||||||
|
import { writeFrontmatter, type WriteFrontmatterParams } from '../tools/frontmatter/write-frontmatter.js';
|
||||||
|
import { cardCrud, type CardCrudParams, type CardData } from '../tools/card/card-crud.js';
|
||||||
|
import { ensureDeckPreview, type EnsureDeckPreviewParams } from '../tools/ensure-deck-preview.js';
|
||||||
|
import {
|
||||||
|
designCardGame,
|
||||||
|
getDesignCardGamePrompt,
|
||||||
|
type DesignCardGameOptions
|
||||||
|
} from '../prompts/design-card-game.js';
|
||||||
|
import {
|
||||||
|
populateDeck,
|
||||||
|
getPopulateDeckPrompt,
|
||||||
|
type PopulateDeckOptions
|
||||||
|
} from '../prompts/populate-deck.js';
|
||||||
|
import {
|
||||||
|
setupDeckDisplay,
|
||||||
|
getSetupDeckDisplayPrompt,
|
||||||
|
type SetupDeckDisplayOptions
|
||||||
|
} from '../prompts/setup-deck-display.js';
|
||||||
|
import {
|
||||||
|
listResources,
|
||||||
|
readResource,
|
||||||
|
type DocResource
|
||||||
|
} from '../resources/docs.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP 服务器命令
|
* MCP 服务器命令
|
||||||
|
|
@ -19,7 +43,7 @@ export const mcpCommand = new Command('mcp')
|
||||||
.description('启动 MCP 服务器')
|
.description('启动 MCP 服务器')
|
||||||
.argument('[host]', '服务器地址', 'stdio')
|
.argument('[host]', '服务器地址', 'stdio')
|
||||||
.option('-p, --port <port>', 'HTTP 端口(仅 HTTP 传输)', '3001')
|
.option('-p, --port <port>', 'HTTP 端口(仅 HTTP 传输)', '3001')
|
||||||
.option('--cwd <dir>', '工作目录(工具调用的相对路径基准)', process.cwd())
|
.option('--cwd <dir>', '工作目录(工具调用的相对路径基准)', process.env.TTRPG_MCP_CWD || process.cwd())
|
||||||
.action(mcpServeAction)
|
.action(mcpServeAction)
|
||||||
)
|
)
|
||||||
.addCommand(
|
.addCommand(
|
||||||
|
|
@ -50,6 +74,10 @@ async function mcpServeAction(host: string, options: MCPOptions) {
|
||||||
const {
|
const {
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
|
ListPromptsRequestSchema,
|
||||||
|
GetPromptRequestSchema,
|
||||||
|
ListResourcesRequestSchema,
|
||||||
|
ReadResourceRequestSchema,
|
||||||
} = await import('@modelcontextprotocol/sdk/types.js');
|
} = await import('@modelcontextprotocol/sdk/types.js');
|
||||||
|
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
|
|
@ -60,6 +88,8 @@ async function mcpServeAction(host: string, options: MCPOptions) {
|
||||||
{
|
{
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
|
prompts: {},
|
||||||
|
resources: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -69,171 +99,351 @@ async function mcpServeAction(host: string, options: MCPOptions) {
|
||||||
return {
|
return {
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
name: 'generate_card_deck',
|
name: 'deck_frontmatter_read',
|
||||||
description: '生成 TTRPG 卡牌组内容,包括 Markdown 介绍文件、CSV 数据文件和 md-deck 组件配置',
|
description: '读取 CSV 文件的 frontmatter(包含模板定义和 deck 配置)',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
deck_name: {
|
csv_file: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: '卡牌组名称,将用于生成文件名',
|
description: 'CSV 文件路径(相对路径相对于 MCP 服务器工作目录)',
|
||||||
},
|
},
|
||||||
output_dir: {
|
},
|
||||||
|
required: ['csv_file'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deck_frontmatter_write',
|
||||||
|
description: '写入/更新 CSV 文件的 frontmatter(模板定义和 deck 配置)',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
csv_file: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: '输出目录路径(相对路径相对于 MCP 服务器工作目录)',
|
description: 'CSV 文件路径',
|
||||||
},
|
},
|
||||||
card_count: {
|
frontmatter: {
|
||||||
type: 'number',
|
|
||||||
description: '卡牌数量',
|
|
||||||
default: 10,
|
|
||||||
},
|
|
||||||
card_template: {
|
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description: '卡牌模板定义',
|
description: '要写入的 frontmatter 数据',
|
||||||
properties: {
|
properties: {
|
||||||
fields: {
|
fields: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: '卡牌字段列表',
|
description: '字段定义列表',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
name: {
|
name: { type: 'string', description: '字段名称' },
|
||||||
type: 'string',
|
description: { type: 'string', description: '字段描述' },
|
||||||
description: '字段名称(英文,用于 CSV 列名)',
|
examples: { type: 'array', items: { type: 'string' }, description: '示例值列表' },
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: 'string',
|
|
||||||
description: '字段描述',
|
|
||||||
},
|
|
||||||
examples: {
|
|
||||||
type: 'array',
|
|
||||||
description: '示例值列表',
|
|
||||||
items: { type: 'string' },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
required: ['name'],
|
required: ['name'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
examples: {
|
deck: {
|
||||||
type: 'array',
|
type: 'object',
|
||||||
description: '完整的卡牌示例数据',
|
description: 'Deck 配置',
|
||||||
items: {
|
properties: {
|
||||||
type: 'object',
|
size: { type: 'string', description: '卡牌尺寸,格式 "宽 x 高"' },
|
||||||
additionalProperties: { type: 'string' },
|
grid: { type: 'string', description: '网格布局,格式 "列 x 行",表示卡牌排版区域分成多少行多少列,用于显示字段。默认使用5x8。' },
|
||||||
|
bleed: { type: 'number', description: '出血边距(mm)' },
|
||||||
|
padding: { type: 'number', description: '内边距(mm)' },
|
||||||
|
shape: { type: 'string', enum: ['rectangle', 'circle', 'hex', 'diamond'] },
|
||||||
|
layers: { type: 'string', description: '字段的显示图层配置。如`title:1,1-5,1f8n body:1,3-5,8f3n`表示将title字段的内容,显示在第1列1行到第5列1行的区域,8mm字体,上侧朝向北,body覆盖1列3行到5列行,3mm字体,上侧朝向北。通常希望重要的辨识字段放在左侧,如`rank:1,1-1,1f6 suit:1,2-1,2f6`。' },
|
||||||
|
back_layers: { type: 'string', description: '背面显示图层配置,与正面类似' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['fields'],
|
|
||||||
},
|
},
|
||||||
deck_config: {
|
merge: {
|
||||||
type: 'object',
|
type: 'boolean',
|
||||||
description: 'md-deck 组件配置',
|
description: '是否合并现有 frontmatter(默认 true)',
|
||||||
properties: {
|
default: true,
|
||||||
size: {
|
},
|
||||||
type: 'string',
|
},
|
||||||
description: '卡牌尺寸,格式 "宽 x 高"(单位 mm)',
|
required: ['csv_file', 'frontmatter'],
|
||||||
default: '54x86',
|
},
|
||||||
},
|
},
|
||||||
grid: {
|
{
|
||||||
type: 'string',
|
name: 'deck_card_crud',
|
||||||
description: '网格布局,格式 "列 x 行"',
|
description: '卡牌 CRUD 操作(创建/读取/更新/删除),支持批量操作',
|
||||||
default: '5x8',
|
inputSchema: {
|
||||||
},
|
type: 'object',
|
||||||
bleed: {
|
properties: {
|
||||||
type: 'number',
|
csv_file: {
|
||||||
description: '出血边距(mm)',
|
type: 'string',
|
||||||
default: 1,
|
description: 'CSV 文件路径',
|
||||||
},
|
},
|
||||||
padding: {
|
action: {
|
||||||
type: 'number',
|
type: 'string',
|
||||||
description: '内边距(mm)',
|
description: '操作类型',
|
||||||
default: 2,
|
enum: ['create', 'read', 'update', 'delete'],
|
||||||
},
|
},
|
||||||
shape: {
|
cards: {
|
||||||
type: 'string',
|
type: ['array', 'object'],
|
||||||
description: '卡牌形状',
|
description: '卡牌数据(单张或数组)',
|
||||||
enum: ['rectangle', 'circle', 'hex', 'diamond'],
|
items: {
|
||||||
default: 'rectangle',
|
type: 'object',
|
||||||
},
|
additionalProperties: { type: 'string', description: "字段内容可以使用markdown语法。使用:[attack]来表示名为attack的图标。使用{{prop}}来引用另一个字段,或者frontmatter里的内容。" },
|
||||||
layers: {
|
|
||||||
type: 'string',
|
|
||||||
description: '正面图层配置,格式 "字段:行,列范围,字体大小"',
|
|
||||||
},
|
|
||||||
back_layers: {
|
|
||||||
type: 'string',
|
|
||||||
description: '背面图层配置',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
label: {
|
||||||
|
type: ['string', 'array'],
|
||||||
|
description: '要操作的卡牌 label(用于 read/update/delete)',
|
||||||
|
items: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['csv_file', 'action'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deck_ensure_preview',
|
||||||
|
description: '确保 CSV 对应的 Markdown 预览文件存在',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
csv_file: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'CSV 文件路径',
|
||||||
|
},
|
||||||
|
md_file: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Markdown 文件路径(可选,默认与 CSV 同名)',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
description: '标题(可选,默认从 CSV 文件名推断)',
|
||||||
|
},
|
||||||
description: {
|
description: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: '卡牌组的介绍描述(可选)',
|
description: '描述(可选)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['deck_name', 'output_dir'],
|
required: ['csv_file'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 处理 Prompts 列表请求
|
||||||
|
server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
||||||
|
return {
|
||||||
|
prompts: [
|
||||||
|
getDesignCardGamePrompt(),
|
||||||
|
getPopulateDeckPrompt(),
|
||||||
|
getSetupDeckDisplayPrompt(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// 处理工具调用请求
|
// 处理工具调用请求
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
const { name, arguments: args } = request.params;
|
const { name, arguments: args } = request.params;
|
||||||
|
|
||||||
if (name === 'generate_card_deck') {
|
try {
|
||||||
try {
|
switch (name) {
|
||||||
const params = args as unknown as GenerateCardDeckParams;
|
case 'generate_card_deck': {
|
||||||
|
const params = args as unknown as GenerateCardDeckParams;
|
||||||
|
|
||||||
|
// 验证必需参数
|
||||||
|
if (!params.deck_name || !params.output_dir) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: '错误:缺少必需参数 deck_name 或 output_dir',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成卡牌组(使用当前工作目录)
|
||||||
|
const result = generateCardDeck(params);
|
||||||
|
|
||||||
// 验证必需参数
|
|
||||||
if (!params.deck_name || !params.output_dir) {
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: '错误:缺少必需参数 deck_name 或 output_dir',
|
text: result.message,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `\n## 生成的组件代码\n\n\`\`\`markdown\n${result.deckComponent}\n\`\`\``,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成卡牌组(使用当前工作目录)
|
case 'deck_frontmatter_read': {
|
||||||
const result = generateCardDeck(params);
|
const params = args as unknown as ReadFrontmatterParams;
|
||||||
|
|
||||||
return {
|
if (!params.csv_file) {
|
||||||
content: [
|
return {
|
||||||
{
|
content: [{ type: 'text', text: '错误:缺少必需参数 csv_file' }],
|
||||||
type: 'text',
|
isError: true,
|
||||||
text: result.message,
|
};
|
||||||
},
|
}
|
||||||
{
|
|
||||||
type: 'text',
|
const result = readFrontmatter(params);
|
||||||
text: `\n## 生成的组件代码\n\n\`\`\`markdown\n${result.deckComponent}\n\`\`\``,
|
|
||||||
},
|
return {
|
||||||
],
|
content: [
|
||||||
};
|
{
|
||||||
} catch (error) {
|
type: 'text',
|
||||||
return {
|
text: result.message,
|
||||||
content: [
|
},
|
||||||
{
|
...(result.frontmatter ? [{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: `生成失败:${error instanceof Error ? error.message : '未知错误'}`,
|
text: `\n## Frontmatter\n\n\`\`\`json\n${JSON.stringify(result.frontmatter, null, 2)}\n\`\`\``,
|
||||||
},
|
}] : []),
|
||||||
],
|
],
|
||||||
isError: true,
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
|
case 'deck_frontmatter_write': {
|
||||||
|
const params = args as unknown as WriteFrontmatterParams;
|
||||||
|
|
||||||
|
if (!params.csv_file || !params.frontmatter) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: '错误:缺少必需参数 csv_file 或 frontmatter' }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = writeFrontmatter(params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: result.success ? `✅ ${result.message}` : `❌ ${result.message}` }],
|
||||||
|
isError: !result.success,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'deck_card_crud': {
|
||||||
|
const params = args as unknown as CardCrudParams;
|
||||||
|
|
||||||
|
if (!params.csv_file || !params.action) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: '错误:缺少必需参数 csv_file 或 action' }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = cardCrud(params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: result.success ? `✅ ${result.message}` : `❌ ${result.message}`,
|
||||||
|
},
|
||||||
|
...(result.cards && result.cards.length > 0 ? [{
|
||||||
|
type: 'text',
|
||||||
|
text: `\n## 卡牌数据\n\n\`\`\`json\n${JSON.stringify(result.cards, null, 2)}\n\`\`\``,
|
||||||
|
}] : []),
|
||||||
|
],
|
||||||
|
isError: !result.success,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'deck_ensure_preview': {
|
||||||
|
const params = args as unknown as EnsureDeckPreviewParams;
|
||||||
|
|
||||||
|
if (!params.csv_file) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: '错误:缺少必需参数 csv_file' }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = ensureDeckPreview(params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: result.success ? `✅ ${result.message}` : `❌ ${result.message}` }],
|
||||||
|
isError: !result.success,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `未知工具:${name}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `工具调用失败:${error instanceof Error ? error.message : '未知错误'}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理 Prompts 获取请求
|
||||||
|
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
||||||
|
const { name, arguments: args } = request.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (name) {
|
||||||
|
case 'design-card-game': {
|
||||||
|
const options = args as unknown as DesignCardGameOptions;
|
||||||
|
const result = designCardGame(options);
|
||||||
|
return {
|
||||||
|
description: '引导用户设计新的卡牌游戏系统,定义卡牌模板和字段结构',
|
||||||
|
messages: result.messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'populate-deck': {
|
||||||
|
const options = args as unknown as PopulateDeckOptions;
|
||||||
|
const result = populateDeck(options);
|
||||||
|
return {
|
||||||
|
description: '为已有的卡牌组生成和填充卡牌内容',
|
||||||
|
messages: result.messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'setup-deck-display': {
|
||||||
|
const options = args as unknown as SetupDeckDisplayOptions;
|
||||||
|
const result = setupDeckDisplay(options);
|
||||||
|
return {
|
||||||
|
description: '引导用户配置卡牌的显示参数(尺寸、布局、样式等)',
|
||||||
|
messages: result.messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`未知 prompt:${name}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理 Resources 列表请求
|
||||||
|
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||||
|
return {
|
||||||
|
resources: listResources().map(r => ({
|
||||||
|
uri: r.uri,
|
||||||
|
name: r.name,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description,
|
||||||
|
mimeType: r.mimeType,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理 Resources 读取请求
|
||||||
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||||
|
const { uri } = request.params;
|
||||||
|
|
||||||
|
const resource = readResource(uri, process.cwd());
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
throw new Error(`Resource not found: ${uri}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
contents: [resource],
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: `未知工具:${name}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isError: true,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
/**
|
||||||
|
* design-card-game Prompt
|
||||||
|
*
|
||||||
|
* 引导用户设计新的卡牌游戏系统,定义卡牌模板和字段结构
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DesignCardGameOptions {
|
||||||
|
/**
|
||||||
|
* 卡牌组名称
|
||||||
|
*/
|
||||||
|
deck_name?: string;
|
||||||
|
/**
|
||||||
|
* 输出目录
|
||||||
|
*/
|
||||||
|
output_dir?: string;
|
||||||
|
/**
|
||||||
|
* 游戏类型/主题
|
||||||
|
*/
|
||||||
|
game_theme?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesignCardGameResult {
|
||||||
|
messages: PromptMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptMessage {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: TextContent | ImageContent | AudioContent | ResourceContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextContent {
|
||||||
|
type: 'text';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageContent {
|
||||||
|
type: 'image';
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioContent {
|
||||||
|
type: 'audio';
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceContent {
|
||||||
|
type: 'resource';
|
||||||
|
resource: {
|
||||||
|
uri: string;
|
||||||
|
mimeType: string;
|
||||||
|
text?: string;
|
||||||
|
blob?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据类型
|
||||||
|
*/
|
||||||
|
export type DataType = 'word' | 'paragraph' | 'number' | 'symbol' | 'symbol_list';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字段功能
|
||||||
|
*/
|
||||||
|
export type FieldFunction = 'identify' | 'compare' | 'flavor' | 'rule';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字段定义
|
||||||
|
*/
|
||||||
|
export interface FieldDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
dataType: DataType;
|
||||||
|
function: FieldFunction;
|
||||||
|
examples?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据类型说明
|
||||||
|
*/
|
||||||
|
const DATA_TYPE_DESCRIPTIONS: Record<DataType, string> = {
|
||||||
|
word: '词语 - 短文本(如名称、类型、职业等)',
|
||||||
|
paragraph: '文本段落 - 长描述(如效果说明、背景故事等)',
|
||||||
|
number: '数字 - 可比较的数值(如费用、强度、等级等)',
|
||||||
|
symbol: '单一符号 - 图标/标记(如阵营符号、元素标志等)',
|
||||||
|
symbol_list: '符号列表 - 多个图标/标记(如属性加成列表、技能图标等)'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字段功能说明
|
||||||
|
*/
|
||||||
|
const FIELD_FUNCTION_DESCRIPTIONS: Record<FieldFunction, string> = {
|
||||||
|
identify: '辨识 - 用于识别卡牌身份(如名称、编号等)',
|
||||||
|
compare: '比较 - 用于游戏机制中的数值比较(如费用、攻击力等)',
|
||||||
|
flavor: '游戏风味描述 - 提供背景故事/氛围(如背景故事、引言等)',
|
||||||
|
rule: '规则确认 - 明确游戏规则相关的信息(如效果文本、触发条件等)'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 design-card-game prompt 的消息
|
||||||
|
*/
|
||||||
|
export function designCardGame(options?: DesignCardGameOptions): DesignCardGameResult {
|
||||||
|
const deckName = options?.deck_name || '未命名卡牌组';
|
||||||
|
const outputDir = options?.output_dir || './content';
|
||||||
|
const gameTheme = options?.game_theme || '通用';
|
||||||
|
|
||||||
|
const messages: PromptMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `我想设计一个"${gameTheme}"主题的卡牌游戏,卡牌组名称为"${deckName}",输出目录为"${outputDir}"。请帮我设计卡牌的字段结构。`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `好的!我来帮你设计"${deckName}"卡牌组的字段结构。
|
||||||
|
|
||||||
|
我们将通过以下步骤完成设计:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 步骤 1:列出卡牌属性
|
||||||
|
|
||||||
|
首先,请列出你的卡牌包含哪些属性(字段)。
|
||||||
|
|
||||||
|
**思考方向:**
|
||||||
|
- 玩家看到这张卡牌时,需要知道什么信息?
|
||||||
|
- 游戏机制需要用到哪些数据?
|
||||||
|
- 哪些信息用于区分不同的卡牌?
|
||||||
|
- 哪些信息用于游戏过程中的判断?
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
- 名称、费用、类型、效果、稀有度...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 步骤 2:定义每个属性的数据类型
|
||||||
|
|
||||||
|
对于每个属性,选择其数据类型:
|
||||||
|
|
||||||
|
| 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| word(词语) | 短文本 | "火球术"、"战士"、"传说" |
|
||||||
|
| paragraph(文本段落) | 长描述 | "造成 5d6 点火焰伤害,范围内的所有生物进行敏捷豁免" |
|
||||||
|
| number(数字) | 可比较的数值 | 3、5、10 |
|
||||||
|
| symbol(单一符号) | 图标/标记 | 🔥、⚔️、🛡️ |
|
||||||
|
| symbol_list(符号列表) | 多个图标/标记 | 🔥🔥🔥、⚔️🛡️💫 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 步骤 3:定义每个字段的功能
|
||||||
|
|
||||||
|
对于每个属性,说明其在游戏中的功能:
|
||||||
|
|
||||||
|
| 功能 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| identify(辨识) | 用于识别卡牌身份 | 名称、编号、唯一标识 |
|
||||||
|
| compare(比较) | 用于游戏机制中的数值比较 | 费用、攻击力、防御力 |
|
||||||
|
| flavor(游戏风味描述) | 提供背景故事/氛围 | 背景故事、引言、 lore |
|
||||||
|
| rule(规则确认) | 明确游戏规则相关的信息 | 效果文本、触发条件、限制 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 步骤 4:关于插画/美术设计
|
||||||
|
|
||||||
|
**默认设定:** 卡牌的背景部分视为插画或精美的装帧设计,不需要在数据中定义插画字段。
|
||||||
|
|
||||||
|
如果你需要在卡牌上显示特定的图标或符号,请使用 symbol 或 symbol_list 类型。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回复格式
|
||||||
|
|
||||||
|
请按以下格式回复(可以复制模板填写):
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
### 属性 1:[属性名称]
|
||||||
|
- **描述**:[简短说明这个属性的用途]
|
||||||
|
- **数据类型**:[word/paragraph/number/symbol/symbol_list]
|
||||||
|
- **字段功能**:[identify/compare/flavor/rule]
|
||||||
|
- **示例值**:[2-3 个示例值]
|
||||||
|
|
||||||
|
### 属性 2:[属性名称]
|
||||||
|
...
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例参考
|
||||||
|
|
||||||
|
### 魔法物品卡牌组
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
### 属性 1:name(名称)
|
||||||
|
- **描述**:物品的名称
|
||||||
|
- **数据类型**:word
|
||||||
|
- **字段功能**:identify
|
||||||
|
- **示例值**:火球术卷轴、治疗药水、隐形斗篷
|
||||||
|
|
||||||
|
### 属性 2:cost(费用)
|
||||||
|
- **描述**:购买或使用该物品所需的资源
|
||||||
|
- **数据类型**:number
|
||||||
|
- **字段功能**:compare
|
||||||
|
- **示例值**:3、5、2
|
||||||
|
|
||||||
|
### 属性 3:type(类型)
|
||||||
|
- **描述**:物品的分类
|
||||||
|
- **数据类型**:word
|
||||||
|
- **字段功能**:identify
|
||||||
|
- **示例值**:武器、防具、饰品、消耗品
|
||||||
|
|
||||||
|
### 属性 4:rarity(稀有度)
|
||||||
|
- **描述**:物品的稀有程度
|
||||||
|
- **数据类型**:word
|
||||||
|
- **字段功能**:compare
|
||||||
|
- **示例值**:普通、稀有、史诗、传说
|
||||||
|
|
||||||
|
### 属性 5:effect(效果)
|
||||||
|
- **描述**:物品的效果或使用说明
|
||||||
|
- **数据类型**:paragraph
|
||||||
|
- **字段功能**:rule
|
||||||
|
- **示例值**:造成 5d6 点火焰伤害、恢复 2d4+2 点生命值
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### 塔罗牌组
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
### 属性 1:name(牌名)
|
||||||
|
- **描述**:塔罗牌的名称
|
||||||
|
- **数据类型**:word
|
||||||
|
- **字段功能**:identify
|
||||||
|
- **示例值**:愚者、魔术师、女祭司
|
||||||
|
|
||||||
|
### 属性 2:number(编号)
|
||||||
|
- **描述**:在大阿卡纳中的序号
|
||||||
|
- **数据类型**:number
|
||||||
|
- **字段功能**:identify
|
||||||
|
- **示例值**:0、1、2
|
||||||
|
|
||||||
|
### 属性 3:element(元素)
|
||||||
|
- **描述**:对应的元素
|
||||||
|
- **数据类型**:symbol
|
||||||
|
- **字段功能**:flavor
|
||||||
|
- **示例值**:🔥、💧、💨、🌍
|
||||||
|
|
||||||
|
### 属性 4:meaning(含义)
|
||||||
|
- **描述**:正位时的含义
|
||||||
|
- **数据类型**:paragraph
|
||||||
|
- **字段功能**:rule
|
||||||
|
- **示例值**:新的开始、冒险、自发性
|
||||||
|
|
||||||
|
### 属性 5:reversed(逆位含义)
|
||||||
|
- **描述**:逆位时的含义
|
||||||
|
- **数据类型**:paragraph
|
||||||
|
- **字段功能**:rule
|
||||||
|
- **示例值**:鲁莽、冒险导致的失败、缺乏计划
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
请开始描述你的卡牌属性吧!`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return { messages };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 prompt 的元数据
|
||||||
|
*/
|
||||||
|
export function getDesignCardGamePrompt() {
|
||||||
|
return {
|
||||||
|
name: 'design-card-game',
|
||||||
|
title: '设计卡牌游戏',
|
||||||
|
description: '引导用户设计新的卡牌游戏系统,定义卡牌模板和字段结构',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'deck_name',
|
||||||
|
description: '卡牌组名称',
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'output_dir',
|
||||||
|
description: '输出目录(相对路径)',
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'game_theme',
|
||||||
|
description: '游戏主题/类型(如:奇幻、科幻、恐怖等)',
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
/**
|
||||||
|
* populate-deck Prompt
|
||||||
|
*
|
||||||
|
* 引导用户为已有的卡牌组填充卡牌内容
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PopulateDeckOptions {
|
||||||
|
/**
|
||||||
|
* CSV 文件路径
|
||||||
|
*/
|
||||||
|
csv_file?: string;
|
||||||
|
/**
|
||||||
|
* 要生成的卡牌数量
|
||||||
|
*/
|
||||||
|
card_count?: number;
|
||||||
|
/**
|
||||||
|
* 卡牌主题/描述
|
||||||
|
*/
|
||||||
|
theme?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopulateDeckResult {
|
||||||
|
messages: PromptMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptMessage {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: TextContent | ImageContent | AudioContent | ResourceContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextContent {
|
||||||
|
type: 'text';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageContent {
|
||||||
|
type: 'image';
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioContent {
|
||||||
|
type: 'audio';
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceContent {
|
||||||
|
type: 'resource';
|
||||||
|
resource: {
|
||||||
|
uri: string;
|
||||||
|
mimeType: string;
|
||||||
|
text?: string;
|
||||||
|
blob?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 populate-deck prompt 的消息
|
||||||
|
*/
|
||||||
|
export function populateDeck(options?: PopulateDeckOptions): PopulateDeckResult {
|
||||||
|
const csvFile = options?.csv_file || './content/deck.csv';
|
||||||
|
const cardCount = options?.card_count || 10;
|
||||||
|
const theme = options?.theme || '通用';
|
||||||
|
|
||||||
|
const messages: PromptMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `我想为卡牌组"${csvFile}"生成${cardCount}张卡牌,主题是"${theme}"。请帮我填充卡牌内容。`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `好的!我来帮你为"${csvFile}"生成${cardCount}张"${theme}"主题的卡牌。
|
||||||
|
|
||||||
|
首先,让我读取现有的卡牌模板结构...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**步骤 1:读取卡牌模板**
|
||||||
|
|
||||||
|
在开始生成卡牌之前,我需要了解卡牌组的字段结构。请确认以下信息:
|
||||||
|
|
||||||
|
## 卡牌字段
|
||||||
|
|
||||||
|
根据 CSV 文件的 frontmatter 定义,卡牌包含以下字段:
|
||||||
|
|
||||||
|
(这里会显示从 CSV 文件中读取的字段定义)
|
||||||
|
|
||||||
|
## 生成方式
|
||||||
|
|
||||||
|
你希望如何生成卡牌内容?
|
||||||
|
|
||||||
|
### 选项 A:完全随机生成
|
||||||
|
- 我会根据字段的 examples 随机组合生成卡牌
|
||||||
|
- 适合快速填充大量卡牌
|
||||||
|
|
||||||
|
### 选项 B:主题化生成
|
||||||
|
- 告诉我具体的主题(如:"森林生物"、"火焰法术"、"古代神器"等)
|
||||||
|
- 我会生成符合主题的连贯卡牌
|
||||||
|
|
||||||
|
### 选项 C:手动指定
|
||||||
|
- 你提供每张卡牌的具体内容
|
||||||
|
- 我帮你格式化并添加到 CSV 中
|
||||||
|
|
||||||
|
### 选项 D:基于现有卡牌扩展
|
||||||
|
- 如果已有部分卡牌,可以基于它们生成相似的卡牌
|
||||||
|
- 保持风格一致性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**示例输出:**
|
||||||
|
|
||||||
|
假设你的卡牌组有以下字段:name, type, cost, effect
|
||||||
|
|
||||||
|
生成 3 张"森林生物"主题的卡牌可能如下:
|
||||||
|
|
||||||
|
\`\`\`json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"label": "1",
|
||||||
|
"name": "荆棘狼",
|
||||||
|
"type": "生物",
|
||||||
|
"cost": "3",
|
||||||
|
"effect": "当荆棘狼进入战场时,对目标对手造成 1 点伤害"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "2",
|
||||||
|
"name": "树精长老",
|
||||||
|
"type": "生物",
|
||||||
|
"cost": "5",
|
||||||
|
"effect": "树精长老具有 +0/+3 和'横置:回复 1 点生命值'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "3",
|
||||||
|
"name": "森林之灵",
|
||||||
|
"type": "生物",
|
||||||
|
"cost": "2",
|
||||||
|
"effect": "飞行,当森林之灵离场时,将一个 1/1 的孢子衍生物放入战场"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
请告诉我:
|
||||||
|
1. 你选择的生成方式(A/B/C/D)
|
||||||
|
2. 如果是选项 B,请提供具体主题
|
||||||
|
3. 是否有特殊要求(如:费用曲线、类型分布等)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return { messages };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 prompt 的元数据
|
||||||
|
*/
|
||||||
|
export function getPopulateDeckPrompt() {
|
||||||
|
return {
|
||||||
|
name: 'populate-deck',
|
||||||
|
title: '填充卡牌内容',
|
||||||
|
description: '为已有的卡牌组生成和填充卡牌内容',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'csv_file',
|
||||||
|
description: 'CSV 文件路径',
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'card_count',
|
||||||
|
description: '要生成的卡牌数量',
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'theme',
|
||||||
|
description: '卡牌主题/描述',
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
/**
|
||||||
|
* setup-deck-display Prompt
|
||||||
|
*
|
||||||
|
* 引导用户配置卡牌的显示参数(尺寸、布局、样式等)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SetupDeckDisplayOptions {
|
||||||
|
/**
|
||||||
|
* CSV 文件路径
|
||||||
|
*/
|
||||||
|
csv_file?: string;
|
||||||
|
/**
|
||||||
|
* 卡牌用途
|
||||||
|
*/
|
||||||
|
usage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupDeckDisplayResult {
|
||||||
|
messages: PromptMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptMessage {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: TextContent | ImageContent | AudioContent | ResourceContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextContent {
|
||||||
|
type: 'text';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageContent {
|
||||||
|
type: 'image';
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioContent {
|
||||||
|
type: 'audio';
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceContent {
|
||||||
|
type: 'resource';
|
||||||
|
resource: {
|
||||||
|
uri: string;
|
||||||
|
mimeType: string;
|
||||||
|
text?: string;
|
||||||
|
blob?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 setup-deck-display prompt 的消息
|
||||||
|
*/
|
||||||
|
export function setupDeckDisplay(options?: SetupDeckDisplayOptions): SetupDeckDisplayResult {
|
||||||
|
const csvFile = options?.csv_file || './content/deck.csv';
|
||||||
|
const usage = options?.usage || '桌面游戏';
|
||||||
|
|
||||||
|
const messages: PromptMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `我想配置"${csvFile}"卡牌组的显示参数,主要用于"${usage}"。请帮我设置合适的尺寸和布局。`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `好的!我来帮你配置"${csvFile}"卡牌组的显示参数。
|
||||||
|
|
||||||
|
## 卡牌尺寸选择
|
||||||
|
|
||||||
|
首先,选择卡牌的标准尺寸:
|
||||||
|
|
||||||
|
### 常见卡牌尺寸
|
||||||
|
|
||||||
|
| 尺寸名称 | 规格 (mm) | 用途 |
|
||||||
|
|---------|----------|------|
|
||||||
|
| 桥牌尺寸 | 57x89 | 传统扑克牌、桌游手牌 |
|
||||||
|
| 标准尺寸 | 63x88 | 万智牌、游戏王等集换式卡牌 |
|
||||||
|
| 迷你尺寸 | 44x68 | 小型道具卡、资源标记 |
|
||||||
|
| 方形尺寸 | 57x57 | 特殊机制卡 |
|
||||||
|
| 塔罗尺寸 | 70x120 | 塔罗牌、神谕卡 |
|
||||||
|
| 自定义 | 输入尺寸 | 特殊需求 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 网格布局
|
||||||
|
|
||||||
|
选择打印时的网格布局(每张 A4 纸的排列):
|
||||||
|
|
||||||
|
| 卡牌尺寸 | 推荐布局 | 每张 A4 数量 |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| 57x89 | 5 列 x 6 行 | 30 张 |
|
||||||
|
| 63x88 | 5 列 x 5 行 | 25 张 |
|
||||||
|
| 44x68 | 6 列 x 8 行 | 48 张 |
|
||||||
|
| 57x57 | 6 列 x 6 行 | 36 张 |
|
||||||
|
| 70x120 | 3 列 x 4 行 | 12 张 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 高级选项
|
||||||
|
|
||||||
|
### 出血边距 (Bleed)
|
||||||
|
- 标准:1mm(推荐)
|
||||||
|
- 无出血:0mm
|
||||||
|
- 大出血:2-3mm(专业印刷)
|
||||||
|
|
||||||
|
### 内边距 (Padding)
|
||||||
|
- 标准:2mm(推荐)
|
||||||
|
- 紧凑:1mm
|
||||||
|
- 宽松:3-4mm
|
||||||
|
|
||||||
|
### 卡牌形状
|
||||||
|
- rectangle(矩形)- 默认
|
||||||
|
- circle(圆形)
|
||||||
|
- hex(六边形)
|
||||||
|
- diamond(菱形)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 图层配置(可选)
|
||||||
|
|
||||||
|
如果你需要在卡牌上自动排版文字,可以配置图层:
|
||||||
|
|
||||||
|
格式:\`字段名:起始行 - 结束行,字体大小\`
|
||||||
|
|
||||||
|
示例:
|
||||||
|
\`\`\`
|
||||||
|
layers="name:1-2,14 type:3-3,10 effect:4-8,9"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
这会将:
|
||||||
|
- name 字段放在第 1-2 行,14 号字体
|
||||||
|
- type 字段放在第 3 行,10 号字体
|
||||||
|
- effect 字段放在第 4-8 行,9 号字体
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**请告诉我:**
|
||||||
|
|
||||||
|
1. 选择的卡牌尺寸(或自定义尺寸)
|
||||||
|
2. 选择的网格布局
|
||||||
|
3. 是否需要调整出血/内边距
|
||||||
|
4. 是否需要配置自动排版图层
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**示例配置:**
|
||||||
|
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"deck": {
|
||||||
|
"size": "63x88",
|
||||||
|
"grid": "5x5",
|
||||||
|
"bleed": 1,
|
||||||
|
"padding": 2,
|
||||||
|
"shape": "rectangle",
|
||||||
|
"layers": "name:1-2,14 type:3-3,10 effect:4-8,9"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`\`\``
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return { messages };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 prompt 的元数据
|
||||||
|
*/
|
||||||
|
export function getSetupDeckDisplayPrompt() {
|
||||||
|
return {
|
||||||
|
name: 'setup-deck-display',
|
||||||
|
title: '配置卡牌显示',
|
||||||
|
description: '引导用户配置卡牌的显示参数(尺寸、布局、样式等)',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'csv_file',
|
||||||
|
description: 'CSV 文件路径',
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'usage',
|
||||||
|
description: '卡牌用途(如:桌面游戏、印刷、在线预览等)',
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档资源定义
|
||||||
|
*/
|
||||||
|
export interface DocResource {
|
||||||
|
uri: string;
|
||||||
|
name: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预定义的文档资源列表
|
||||||
|
*/
|
||||||
|
export const DOC_RESOURCES: DocResource[] = [
|
||||||
|
{
|
||||||
|
uri: 'ttrpg-docs://csv',
|
||||||
|
name: 'csv.md',
|
||||||
|
title: 'CSV 编写说明',
|
||||||
|
description: 'TTRPG Tools CSV 文件格式说明,包括 Front Matter、字段定义、变量语法等',
|
||||||
|
mimeType: 'text/markdown'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ttrpg-docs://markdown',
|
||||||
|
name: 'markdown.md',
|
||||||
|
title: 'Markdown 编写说明',
|
||||||
|
description: 'TTRPG Tools Markdown 扩展语法和组件用法说明',
|
||||||
|
mimeType: 'text/markdown'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取资源列表
|
||||||
|
*/
|
||||||
|
export function listResources(): DocResource[] {
|
||||||
|
return DOC_RESOURCES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取资源内容
|
||||||
|
* @param uri 资源 URI
|
||||||
|
* @param cwd 工作目录
|
||||||
|
* @returns 资源内容
|
||||||
|
*/
|
||||||
|
export function readResource(uri: string, cwd: string): {
|
||||||
|
uri: string;
|
||||||
|
mimeType: string;
|
||||||
|
text: string;
|
||||||
|
} | null {
|
||||||
|
// 解析 URI
|
||||||
|
const docName = uri.replace('ttrpg-docs://', '');
|
||||||
|
const fileName = `${docName}.md`;
|
||||||
|
const filePath = join(cwd, 'docs', fileName);
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件内容
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
return {
|
||||||
|
uri,
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
text: content
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to read resource ${uri}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
|
import { parse } from 'csv-parse/browser/esm/sync';
|
||||||
|
import { stringify } from 'csv-stringify/browser/esm/sync';
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
import type { DeckFrontmatter } from '../frontmatter/read-frontmatter.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡牌数据
|
||||||
|
*/
|
||||||
|
export interface CardData {
|
||||||
|
label?: string;
|
||||||
|
[key: string]: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡牌 CRUD 参数
|
||||||
|
*/
|
||||||
|
export interface CardCrudParams {
|
||||||
|
/**
|
||||||
|
* CSV 文件路径
|
||||||
|
*/
|
||||||
|
csv_file: string;
|
||||||
|
/**
|
||||||
|
* 操作类型
|
||||||
|
*/
|
||||||
|
action: 'create' | 'read' | 'update' | 'delete';
|
||||||
|
/**
|
||||||
|
* 卡牌数据(单张或数组)
|
||||||
|
*/
|
||||||
|
cards?: CardData | CardData[];
|
||||||
|
/**
|
||||||
|
* 要读取/更新的卡牌 label(用于 read/update/delete)
|
||||||
|
*/
|
||||||
|
label?: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡牌 CRUD 结果
|
||||||
|
*/
|
||||||
|
export interface CardCrudResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
cards?: CardData[];
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 CSV 文件的 frontmatter
|
||||||
|
*/
|
||||||
|
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
||||||
|
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||||
|
|
||||||
|
if (parts.length !== 3 || parts[0] !== '') {
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frontmatterStr = parts[1].trim();
|
||||||
|
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
||||||
|
const csvContent = parts.slice(2).join('---\n').trimStart();
|
||||||
|
return { frontmatter, csvContent };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse front matter:', error);
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化 frontmatter 为 YAML 字符串
|
||||||
|
*/
|
||||||
|
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
|
||||||
|
const yamlStr = yaml.dump(frontmatter, {
|
||||||
|
indent: 2,
|
||||||
|
lineWidth: -1,
|
||||||
|
noRefs: true,
|
||||||
|
quotingType: '"',
|
||||||
|
forceQuotes: false
|
||||||
|
});
|
||||||
|
return `---\n${yamlStr}---\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载 CSV 数据(包含 frontmatter)
|
||||||
|
*/
|
||||||
|
function loadCSVWithFrontmatter(filePath: string): {
|
||||||
|
frontmatter?: DeckFrontmatter;
|
||||||
|
records: CardData[];
|
||||||
|
headers: string[];
|
||||||
|
} {
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
const { frontmatter, csvContent } = parseFrontMatter(content);
|
||||||
|
|
||||||
|
const records = parse(csvContent, {
|
||||||
|
columns: true,
|
||||||
|
comment: '#',
|
||||||
|
trim: true,
|
||||||
|
skipEmptyLines: true
|
||||||
|
}) as CardData[];
|
||||||
|
|
||||||
|
// 获取表头
|
||||||
|
const firstLine = csvContent.split('\n')[0];
|
||||||
|
const headers = firstLine.split(',').map(h => h.trim());
|
||||||
|
|
||||||
|
return { frontmatter, records, headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存 CSV 数据(包含 frontmatter)
|
||||||
|
*/
|
||||||
|
function saveCSVWithFrontmatter(
|
||||||
|
filePath: string,
|
||||||
|
frontmatter: DeckFrontmatter | undefined,
|
||||||
|
records: CardData[],
|
||||||
|
headers?: string[]
|
||||||
|
): void {
|
||||||
|
// 序列化 frontmatter
|
||||||
|
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : '';
|
||||||
|
|
||||||
|
// 确定表头
|
||||||
|
if (!headers || headers.length === 0) {
|
||||||
|
// 从 records 和 frontmatter.fields 推断表头
|
||||||
|
headers = ['label'];
|
||||||
|
if (frontmatter?.fields && Array.isArray(frontmatter.fields)) {
|
||||||
|
for (const field of frontmatter.fields) {
|
||||||
|
if (field.name && typeof field.name === 'string') {
|
||||||
|
headers.push(field.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headers.push('body');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保所有 record 都有 headers 中的列
|
||||||
|
for (const record of records) {
|
||||||
|
for (const header of headers) {
|
||||||
|
if (!(header in record)) {
|
||||||
|
record[header] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化 CSV
|
||||||
|
const csvContent = stringify(records, {
|
||||||
|
header: true,
|
||||||
|
columns: headers
|
||||||
|
});
|
||||||
|
|
||||||
|
// 写入文件
|
||||||
|
writeFileSync(filePath, frontmatterStr + csvContent, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成下一个 label
|
||||||
|
*/
|
||||||
|
function generateNextLabel(records: CardData[]): string {
|
||||||
|
const maxLabel = records.reduce((max, record) => {
|
||||||
|
const label = record.label ? parseInt(record.label, 10) : 0;
|
||||||
|
return label > max ? label : max;
|
||||||
|
}, 0);
|
||||||
|
return (maxLabel + 1).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡牌 CRUD 操作
|
||||||
|
*/
|
||||||
|
export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||||
|
const { csv_file, action, cards, label } = params;
|
||||||
|
|
||||||
|
// 检查文件是否存在(create 操作可以不存在)
|
||||||
|
if (action !== 'create' && !existsSync(csv_file)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `文件不存在:${csv_file}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let frontmatter: DeckFrontmatter | undefined;
|
||||||
|
let records: CardData[] = [];
|
||||||
|
let headers: string[] = [];
|
||||||
|
|
||||||
|
// 加载现有数据
|
||||||
|
if (existsSync(csv_file)) {
|
||||||
|
const data = loadCSVWithFrontmatter(csv_file);
|
||||||
|
frontmatter = data.frontmatter;
|
||||||
|
records = data.records;
|
||||||
|
headers = data.headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行操作
|
||||||
|
switch (action) {
|
||||||
|
case 'create': {
|
||||||
|
const newCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
|
||||||
|
for (const card of newCards) {
|
||||||
|
if (!card.label) {
|
||||||
|
card.label = generateNextLabel(records);
|
||||||
|
}
|
||||||
|
records.push(card);
|
||||||
|
}
|
||||||
|
saveCSVWithFrontmatter(csv_file, frontmatter, records, headers);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `成功创建 ${newCards.length} 张卡牌`,
|
||||||
|
cards: newCards,
|
||||||
|
count: newCards.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'read': {
|
||||||
|
const labelsToRead = Array.isArray(label) ? label : (label ? [label] : null);
|
||||||
|
let resultCards: CardData[];
|
||||||
|
|
||||||
|
if (labelsToRead && labelsToRead.length > 0) {
|
||||||
|
resultCards = records.filter(r => labelsToRead.includes(r.label || ''));
|
||||||
|
} else {
|
||||||
|
resultCards = records;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `成功读取 ${resultCards.length} 张卡牌`,
|
||||||
|
cards: resultCards,
|
||||||
|
count: resultCards.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'update': {
|
||||||
|
const labelsToUpdate = Array.isArray(label) ? label : (label ? [label] : null);
|
||||||
|
const updateCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
|
||||||
|
let updatedCount = 0;
|
||||||
|
|
||||||
|
if (labelsToUpdate && labelsToUpdate.length > 0) {
|
||||||
|
// 按 label 更新
|
||||||
|
for (const updateCard of updateCards) {
|
||||||
|
const targetLabel = updateCard.label || labelsToUpdate[updatedCount % labelsToUpdate.length];
|
||||||
|
const index = records.findIndex(r => r.label === targetLabel);
|
||||||
|
if (index !== -1) {
|
||||||
|
records[index] = { ...records[index], ...updateCard };
|
||||||
|
updatedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 按 cards 中的 label 更新
|
||||||
|
for (const updateCard of updateCards) {
|
||||||
|
if (updateCard.label) {
|
||||||
|
const index = records.findIndex(r => r.label === updateCard.label);
|
||||||
|
if (index !== -1) {
|
||||||
|
records[index] = { ...records[index], ...updateCard };
|
||||||
|
updatedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCSVWithFrontmatter(csv_file, frontmatter, records, headers);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `成功更新 ${updatedCount} 张卡牌`,
|
||||||
|
cards: updateCards,
|
||||||
|
count: updatedCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete': {
|
||||||
|
const labelsToDelete = Array.isArray(label) ? label : (label ? [label] : null);
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
if (labelsToDelete && labelsToDelete.length > 0) {
|
||||||
|
const beforeCount = records.length;
|
||||||
|
records = records.filter(r => !labelsToDelete.includes(r.label || ''));
|
||||||
|
deletedCount = beforeCount - records.length;
|
||||||
|
} else if (cards) {
|
||||||
|
const cardsToDelete = Array.isArray(cards) ? cards : [cards];
|
||||||
|
const beforeCount = records.length;
|
||||||
|
records = records.filter(r =>
|
||||||
|
!cardsToDelete.some(c => c.label && r.label === c.label)
|
||||||
|
);
|
||||||
|
deletedCount = beforeCount - records.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCSVWithFrontmatter(csv_file, frontmatter, records, headers);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `成功删除 ${deletedCount} 张卡牌`,
|
||||||
|
count: deletedCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `未知操作:${action}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `操作失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
|
import { dirname, join, basename, extname } from 'path';
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保 Deck 预览文件的参数
|
||||||
|
*/
|
||||||
|
export interface EnsureDeckPreviewParams {
|
||||||
|
/**
|
||||||
|
* CSV 文件路径
|
||||||
|
*/
|
||||||
|
csv_file: string;
|
||||||
|
/**
|
||||||
|
* Markdown 文件路径(可选,默认与 CSV 同名)
|
||||||
|
*/
|
||||||
|
md_file?: string;
|
||||||
|
/**
|
||||||
|
* 标题(可选,默认从 CSV 文件名推断)
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
/**
|
||||||
|
* 描述(可选)
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保 Deck 预览文件的结果
|
||||||
|
*/
|
||||||
|
export interface EnsureDeckPreviewResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
md_file?: string;
|
||||||
|
created?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 CSV 文件的 frontmatter
|
||||||
|
*/
|
||||||
|
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
||||||
|
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||||
|
|
||||||
|
if (parts.length !== 3 || parts[0] !== '') {
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frontmatterStr = parts[1].trim();
|
||||||
|
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
||||||
|
const csvContent = parts.slice(2).join('---\n').trimStart();
|
||||||
|
return { frontmatter, csvContent };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse front matter:', error);
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 md-deck 组件代码
|
||||||
|
*/
|
||||||
|
function buildDeckComponent(csvPath: string, config?: DeckConfig): string {
|
||||||
|
const parts = [`:md-deck[${csvPath}]`];
|
||||||
|
const attrs: string[] = [];
|
||||||
|
|
||||||
|
if (config?.size) {
|
||||||
|
attrs.push(`size="${config.size}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.grid) {
|
||||||
|
attrs.push(`grid="${config.grid}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.bleed !== undefined && config.bleed !== 1) {
|
||||||
|
attrs.push(`bleed="${config.bleed}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.padding !== undefined && config.padding !== 2) {
|
||||||
|
attrs.push(`padding="${config.padding}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.shape && config.shape !== 'rectangle') {
|
||||||
|
attrs.push(`shape="${config.shape}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.layers) {
|
||||||
|
attrs.push(`layers="${config.layers}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.back_layers) {
|
||||||
|
attrs.push(`back-layers="${config.back_layers}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.length > 0) {
|
||||||
|
parts.push(`{${attrs.join(' ')}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保 Markdown 预览文件存在
|
||||||
|
*/
|
||||||
|
export function ensureDeckPreview(params: EnsureDeckPreviewParams): EnsureDeckPreviewResult {
|
||||||
|
const { csv_file, md_file, title, description } = params;
|
||||||
|
|
||||||
|
// 检查 CSV 文件是否存在
|
||||||
|
if (!existsSync(csv_file)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `CSV 文件不存在:${csv_file}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定 Markdown 文件路径
|
||||||
|
let mdFilePath = md_file;
|
||||||
|
if (!mdFilePath) {
|
||||||
|
// 使用与 CSV 相同的路径和文件名,只是扩展名不同
|
||||||
|
const dir = dirname(csv_file);
|
||||||
|
const name = basename(csv_file, extname(csv_file));
|
||||||
|
mdFilePath = join(dir, `${name}.md`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = !existsSync(mdFilePath);
|
||||||
|
|
||||||
|
// 如果文件已存在,检查是否已有 md-deck 组件
|
||||||
|
if (!created) {
|
||||||
|
try {
|
||||||
|
const existingContent = readFileSync(mdFilePath, 'utf-8');
|
||||||
|
// 如果已有 md-deck 组件引用该 CSV,直接返回
|
||||||
|
if (existingContent.includes(`:md-deck[${csv_file}]`) ||
|
||||||
|
existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `预览文件 ${mdFilePath} 已存在`,
|
||||||
|
md_file: mdFilePath,
|
||||||
|
created: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 读取失败,继续创建
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 CSV 的 frontmatter 获取配置
|
||||||
|
let deckConfig: DeckConfig | undefined;
|
||||||
|
try {
|
||||||
|
const csvContent = readFileSync(csv_file, 'utf-8');
|
||||||
|
const { frontmatter } = parseFrontMatter(csvContent);
|
||||||
|
deckConfig = frontmatter?.deck;
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略错误,使用默认配置
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定标题
|
||||||
|
let deckTitle = title;
|
||||||
|
if (!deckTitle) {
|
||||||
|
// 从 CSV 文件名推断
|
||||||
|
const name = basename(csv_file, extname(csv_file));
|
||||||
|
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 md-deck 组件代码(使用相对路径)
|
||||||
|
const mdDir = dirname(mdFilePath);
|
||||||
|
const csvDir = dirname(csv_file);
|
||||||
|
let relativeCsvPath: string;
|
||||||
|
|
||||||
|
if (mdDir === csvDir) {
|
||||||
|
relativeCsvPath = `./${basename(csv_file)}`;
|
||||||
|
} else {
|
||||||
|
relativeCsvPath = `./${basename(csv_file)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
|
||||||
|
|
||||||
|
// 生成 Markdown 内容
|
||||||
|
const mdLines: string[] = [];
|
||||||
|
|
||||||
|
mdLines.push(`# ${deckTitle}`);
|
||||||
|
mdLines.push('');
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
mdLines.push(description);
|
||||||
|
mdLines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
mdLines.push('## 卡牌预览');
|
||||||
|
mdLines.push('');
|
||||||
|
mdLines.push(deckComponent);
|
||||||
|
mdLines.push('');
|
||||||
|
|
||||||
|
mdLines.push('## 使用说明');
|
||||||
|
mdLines.push('');
|
||||||
|
mdLines.push('- 点击卡牌可以查看详情');
|
||||||
|
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
|
||||||
|
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
|
||||||
|
mdLines.push('');
|
||||||
|
|
||||||
|
// 写入文件
|
||||||
|
try {
|
||||||
|
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: created
|
||||||
|
? `创建预览文件 ${mdFilePath}`
|
||||||
|
: `更新预览文件 ${mdFilePath}`,
|
||||||
|
md_file: mdFilePath,
|
||||||
|
created
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 CSV frontmatter 的参数
|
||||||
|
*/
|
||||||
|
export interface ReadFrontmatterParams {
|
||||||
|
/**
|
||||||
|
* CSV 文件路径(相对路径相对于 MCP 服务器工作目录)
|
||||||
|
*/
|
||||||
|
csv_file: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frontmatter 数据结构
|
||||||
|
*/
|
||||||
|
export interface DeckFrontmatter {
|
||||||
|
/**
|
||||||
|
* 字段定义
|
||||||
|
*/
|
||||||
|
fields?: CardField[];
|
||||||
|
/**
|
||||||
|
* Deck 配置
|
||||||
|
*/
|
||||||
|
deck?: DeckConfig;
|
||||||
|
/**
|
||||||
|
* 其他自定义属性
|
||||||
|
*/
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字段样式配置
|
||||||
|
*
|
||||||
|
* 位置按照卡牌网格,安排 x,y,w,h 四个整数
|
||||||
|
* 如 5x8 网格中 1,1,5,8 表示覆盖整个卡牌的区域
|
||||||
|
*
|
||||||
|
* 字体使用 f:8 来表示 8mm 字体
|
||||||
|
*
|
||||||
|
* 朝向使用 u:n/w/s/e 表示字段的上侧朝向北西南东
|
||||||
|
*/
|
||||||
|
export interface CardFieldStyle {
|
||||||
|
/**
|
||||||
|
* 位置:[x, y, w, h] 网格坐标和尺寸
|
||||||
|
* 例如:[1, 1, 5, 8] 表示从 (1,1) 开始,宽 5 格,高 8 格
|
||||||
|
*/
|
||||||
|
pos?: [number, number, number, number];
|
||||||
|
/**
|
||||||
|
* 字体大小:格式 "f:8" 表示 8mm 字体
|
||||||
|
*/
|
||||||
|
font?: string;
|
||||||
|
/**
|
||||||
|
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
|
||||||
|
*/
|
||||||
|
up?: 'n' | 'w' | 's' | 'e';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡牌字段定义
|
||||||
|
*/
|
||||||
|
export interface CardField {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
examples?: string[];
|
||||||
|
style?: CardFieldStyle;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deck 配置
|
||||||
|
*/
|
||||||
|
export interface DeckConfig {
|
||||||
|
size?: string;
|
||||||
|
grid?: string;
|
||||||
|
bleed?: number;
|
||||||
|
padding?: number;
|
||||||
|
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
|
||||||
|
layers?: string;
|
||||||
|
back_layers?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 CSV frontmatter 的结果
|
||||||
|
*/
|
||||||
|
export interface ReadFrontmatterResult {
|
||||||
|
success: boolean;
|
||||||
|
frontmatter?: DeckFrontmatter;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 CSV 文件的 frontmatter
|
||||||
|
*/
|
||||||
|
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
||||||
|
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||||
|
|
||||||
|
// 至少需要三个部分:空字符串、front matter、CSV 内容
|
||||||
|
if (parts.length !== 3 || parts[0] !== '') {
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frontmatterStr = parts[1].trim();
|
||||||
|
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
||||||
|
const csvContent = parts.slice(2).join('---\n').trimStart();
|
||||||
|
return { frontmatter, csvContent };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse front matter:', error);
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 CSV 文件的 frontmatter
|
||||||
|
*/
|
||||||
|
export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterResult {
|
||||||
|
const { csv_file } = params;
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if (!existsSync(csv_file)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `文件不存在:${csv_file}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 读取文件内容
|
||||||
|
const content = readFileSync(csv_file, 'utf-8');
|
||||||
|
|
||||||
|
// 解析 frontmatter
|
||||||
|
const { frontmatter } = parseFrontMatter(content);
|
||||||
|
|
||||||
|
if (!frontmatter) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
frontmatter: {},
|
||||||
|
message: `文件 ${csv_file} 没有 frontmatter,返回空对象`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
frontmatter,
|
||||||
|
message: `成功读取 ${csv_file} 的 frontmatter`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `读取失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
import type { DeckFrontmatter } from './read-frontmatter.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入 CSV frontmatter 的参数
|
||||||
|
*/
|
||||||
|
export interface WriteFrontmatterParams {
|
||||||
|
/**
|
||||||
|
* CSV 文件路径(相对路径相对于 MCP 服务器工作目录)
|
||||||
|
*/
|
||||||
|
csv_file: string;
|
||||||
|
/**
|
||||||
|
* 要写入的 frontmatter 数据
|
||||||
|
*/
|
||||||
|
frontmatter: DeckFrontmatter;
|
||||||
|
/**
|
||||||
|
* 是否合并现有 frontmatter(默认 true)
|
||||||
|
*/
|
||||||
|
merge?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入 CSV frontmatter 的结果
|
||||||
|
*/
|
||||||
|
export interface WriteFrontmatterResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
csv_file?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 CSV 文件的 frontmatter
|
||||||
|
*/
|
||||||
|
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
||||||
|
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||||
|
|
||||||
|
// 至少需要三个部分:空字符串、front matter、CSV 内容
|
||||||
|
if (parts.length !== 3 || parts[0] !== '') {
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frontmatterStr = parts[1].trim();
|
||||||
|
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
||||||
|
const csvContent = parts.slice(2).join('---\n').trimStart();
|
||||||
|
return { frontmatter, csvContent };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse front matter:', error);
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化 frontmatter 为 YAML 字符串
|
||||||
|
*/
|
||||||
|
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
|
||||||
|
const yamlStr = yaml.dump(frontmatter, {
|
||||||
|
indent: 2,
|
||||||
|
lineWidth: -1, // 不限制行宽
|
||||||
|
noRefs: true, // 不使用引用
|
||||||
|
quotingType: '"',
|
||||||
|
forceQuotes: false
|
||||||
|
});
|
||||||
|
return `---\n${yamlStr}---\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入/更新 CSV 文件的 frontmatter
|
||||||
|
*/
|
||||||
|
export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatterResult {
|
||||||
|
const { csv_file, frontmatter, merge = true } = params;
|
||||||
|
|
||||||
|
let csvContent = '';
|
||||||
|
let existingFrontmatter: DeckFrontmatter | undefined;
|
||||||
|
|
||||||
|
// 如果文件存在且需要合并,先读取现有内容
|
||||||
|
if (merge && existsSync(csv_file)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(csv_file, 'utf-8');
|
||||||
|
const result = parseFrontMatter(content);
|
||||||
|
existingFrontmatter = result.frontmatter;
|
||||||
|
csvContent = result.csvContent;
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `读取现有文件失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并或替换 frontmatter
|
||||||
|
const finalFrontmatter = merge && existingFrontmatter
|
||||||
|
? { ...existingFrontmatter, ...frontmatter }
|
||||||
|
: frontmatter;
|
||||||
|
|
||||||
|
// 序列化 frontmatter
|
||||||
|
const frontmatterStr = serializeFrontMatter(finalFrontmatter);
|
||||||
|
|
||||||
|
// 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容
|
||||||
|
if (!csvContent.trim()) {
|
||||||
|
// 从 frontmatter 推断 CSV 表头
|
||||||
|
const headers = ['label'];
|
||||||
|
if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) {
|
||||||
|
for (const field of finalFrontmatter.fields) {
|
||||||
|
if (field.name && typeof field.name === 'string') {
|
||||||
|
headers.push(field.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headers.push('body');
|
||||||
|
csvContent = headers.join(',') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入文件
|
||||||
|
try {
|
||||||
|
const fullContent = frontmatterStr + csvContent;
|
||||||
|
writeFileSync(csv_file, fullContent, 'utf-8');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `成功写入 frontmatter 到 ${csv_file}`,
|
||||||
|
csv_file
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,32 @@
|
||||||
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字段样式配置
|
||||||
|
*
|
||||||
|
* 位置按照卡牌网格,安排 x,y,w,h 四个整数
|
||||||
|
* 如 5x8 网格中 1,1,5,8 表示覆盖整个卡牌的区域
|
||||||
|
*
|
||||||
|
* 字体使用 f:8 来表示 8mm 字体
|
||||||
|
*
|
||||||
|
* 朝向使用 u:n/w/s/e 表示字段的上侧朝向北西南东
|
||||||
|
*/
|
||||||
|
export interface CardFieldStyle {
|
||||||
|
/**
|
||||||
|
* 位置:[x, y, w, h] 网格坐标和尺寸
|
||||||
|
* 例如:[1, 1, 5, 8] 表示从 (1,1) 开始,宽 5 格,高 8 格
|
||||||
|
*/
|
||||||
|
pos?: [number, number, number, number];
|
||||||
|
/**
|
||||||
|
* 字体大小:格式 "f:8" 表示 8mm 字体
|
||||||
|
*/
|
||||||
|
font?: string;
|
||||||
|
/**
|
||||||
|
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
|
||||||
|
*/
|
||||||
|
up?: 'n' | 'w' | 's' | 'e';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 卡牌字段定义
|
* 卡牌字段定义
|
||||||
*/
|
*/
|
||||||
|
|
@ -8,6 +34,7 @@ export interface CardField {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
examples?: string[];
|
examples?: string[];
|
||||||
|
style?: CardFieldStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@ function ensureIndexLoaded(): Promise<void> {
|
||||||
if (indexLoadPromise) return indexLoadPromise;
|
if (indexLoadPromise) return indexLoadPromise;
|
||||||
|
|
||||||
indexLoadPromise = (async () => {
|
indexLoadPromise = (async () => {
|
||||||
// 尝试 CLI 环境:从 /__FILE_INDEX.json 加载
|
// 尝试 CLI 环境:从 /__CONTENT_INDEX.json 加载
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/__FILE_INDEX.json");
|
const response = await fetch("/__CONTENT_INDEX.json");
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const index = await response.json();
|
const index = await response.json();
|
||||||
fileIndex = { ...fileIndex, ...index };
|
fileIndex = { ...fileIndex, ...index };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue