Compare commits
18
Commits
b5591f2a78
...
f9ea37427b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9ea37427b | ||
|
|
ebb49ddfca | ||
|
|
322287da29 | ||
|
|
08bc97bec5 | ||
|
|
264038e27c | ||
|
|
e838f9a4b1 | ||
|
|
3532be2335 | ||
|
|
f17f09a8c4 | ||
|
|
3508286792 | ||
|
|
0b029b325e | ||
|
|
0ac9a833e4 | ||
|
|
fadfd4cba6 | ||
|
|
16f2038fd8 | ||
|
|
02442a1100 | ||
|
|
9f372fa2b0 | ||
|
|
a26d53cbeb | ||
|
|
0ce91afc49 | ||
|
|
c7f3b3425b |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: tttk-authoring
|
name: tttk-authoring
|
||||||
description: Author and edit TTRPG game design documents for the tttk (Tabletop Toolkit) CLI. Use when writing or modifying markdown content that uses tttk custom elements (:md-dice, :md-table, :md-deck, :md-pins, :md-commander, etc.), custom fenced code blocks (role=spark-table / declare / file / tag), companion CSV data files, or when previewing content with `tttk serve` and validating it with `tttk check`.
|
description: Author and edit TTRPG game design documents for the tttk (Tabletop Toolkit) CLI. Use when writing or modifying markdown content that uses tttk custom elements (:md-dice, :md-table, :md-deck, :md-pins, etc.), custom fenced code blocks (role=spark-table / declare / file / tag), companion CSV data files, or when previewing content with `tttk serve` and validating it with `tttk check`.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Authoring tttk game design documents
|
# Authoring tttk game design documents
|
||||||
@@ -54,13 +54,15 @@ in `reference.md`.
|
|||||||
|
|
||||||
## Code block roles
|
## Code block roles
|
||||||
|
|
||||||
Fenced blocks are dispatched by the `role=` attribute in the info string:
|
Fenced blocks are dispatched by the `role=` attribute in the info string. Both
|
||||||
|
backtick and tilde (`~~~`) fences work; the closing fence must reuse the opening
|
||||||
|
marker, and fence attribute keys may contain hyphens (e.g. `data-foo=bar`):
|
||||||
|
|
||||||
| Info string | Behavior |
|
| Info string | Behavior |
|
||||||
|---|---|
|
|---|---|
|
||||||
| ` ```csv role=spark-table ` | Body is CSV; replaced with `:md-table[./csv_<hash>]`. First column header must be a dice formula (`d6`, `2d6+1`, …). |
|
| ` ```csv role=spark-table ` | Body is CSV; replaced with `:md-table[./csv_<hash>]`. First column header must be a dice formula (`d6`, `2d6+1`, …). |
|
||||||
| ` ```markdown role=spark-table ` | Body is a GFM pipe table; converted to CSV, then same as above. |
|
| ` ```markdown role=spark-table ` | Body is a GFM pipe table; converted to CSV, then same as above. |
|
||||||
| ` ```csv role=declare ` | Defines reactive variables / tag modifiers. Block is stripped. |
|
| ` ```yaml role=declare ` | YAML `vars:` / `tags:` maps defining reactive variables and tag modifiers. Block is stripped. |
|
||||||
| ` ```<lang> role=file ` | Body stored as text content, block stripped, referenced by its derived id. |
|
| ` ```<lang> role=file ` | Body stored as text content, block stripped, referenced by its derived id. |
|
||||||
| ` ```yaml role=tag ` | Kept intact; rendered as a custom element from YAML (`tag:`, `body:`, …). |
|
| ` ```yaml role=tag ` | Kept intact; rendered as a custom element from YAML (`tag:`, `body:`, …). |
|
||||||
| ` ```<lang> ` (no role) | Rendered as a normal visible code block. |
|
| ` ```<lang> ` (no role) | Rendered as a normal visible code block. |
|
||||||
@@ -109,7 +111,7 @@ Warnings are usually real too:
|
|||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `TTTK-R002` | spark-table first header is not a dice formula |
|
| `TTTK-R002` | spark-table first header is not a dice formula |
|
||||||
| `TTTK-R004` | `role=declare` row skipped (empty `key` or `expr`) |
|
| `TTTK-R004` | `role=declare` block declares nothing |
|
||||||
| `TTTK-C002` | reference resolves to an unexpected file type |
|
| `TTTK-C002` | reference resolves to an unexpected file type |
|
||||||
| `TTTK-L002` | `#section` matches no heading |
|
| `TTTK-L002` | `#section` matches no heading |
|
||||||
| `TTTK-A001` | icon missing under `assets/` |
|
| `TTTK-A001` | icon missing under `assets/` |
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ Nest with more colons (`::::`, `:::::`).
|
|||||||
| `:md-border` | color or border image | `width`, `slice` |
|
| `:md-border` | color or border image | `width`, `slice` |
|
||||||
| `:md-font` | font family | `source`, `weight`, `color` |
|
| `:md-font` | font family | `source`, `weight`, `color` |
|
||||||
| `:md-pins` | image path | `pins`, `fixed`, `labelStart` |
|
| `:md-pins` | image path | `pins`, `fixed`, `labelStart` |
|
||||||
| `:md-commander` | command template CSV | `placeholder`, `class`, `height` |
|
|
||||||
| `:md-yarn-spinner` | Yarn Spinner file | `start` |
|
| `:md-yarn-spinner` | Yarn Spinner file | `start` |
|
||||||
|
|
||||||
### `:md-dice`
|
### `:md-dice`
|
||||||
@@ -205,19 +204,6 @@ Numbered markers on an image.
|
|||||||
:md-pins[./images/dungeon.png]{labelStart="1"}
|
:md-pins[./images/dungeon.png]{labelStart="1"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `:md-commander`
|
|
||||||
|
|
||||||
Interactive command history / tracker panel.
|
|
||||||
|
|
||||||
The body is a CSV of command templates (`command,parameter,label,description,insertedText`).
|
|
||||||
|
|
||||||
- Body: command template CSV
|
|
||||||
- Attributes: `placeholder`, `class`, `height`
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
:md-commander[./commands.csv]{placeholder="输入命令…" height="400px"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `:md-yarn-spinner`
|
### `:md-yarn-spinner`
|
||||||
|
|
||||||
Runs a Yarn Spinner dialogue file.
|
Runs a Yarn Spinner dialogue file.
|
||||||
@@ -270,6 +256,10 @@ Renders a clickable span that dispatches a journal command.
|
|||||||
The `role=` attribute on the info string determines how a fenced block is
|
The `role=` attribute on the info string determines how a fenced block is
|
||||||
handled.
|
handled.
|
||||||
|
|
||||||
|
Fences may use backticks or tildes (`~~~`); the closing fence must reuse
|
||||||
|
the opening marker. Fence attribute keys may contain hyphens (e.g.
|
||||||
|
`data-foo=bar`).
|
||||||
|
|
||||||
### `role=spark-table`
|
### `role=spark-table`
|
||||||
|
|
||||||
CSV (or markdown pipe table) rendered as `:md-table`.
|
CSV (or markdown pipe table) rendered as `:md-table`.
|
||||||
@@ -297,13 +287,19 @@ d6,结果
|
|||||||
|
|
||||||
Reactive variable / tag-modifier definitions.
|
Reactive variable / tag-modifier definitions.
|
||||||
|
|
||||||
Columns (any order): `tag`, `threshold`, `key`, `expr`. A row with an empty `tag` declares a variable; a row with a `#tag` applies a modifier when that tag is active.
|
YAML with two optional maps. `vars:` maps a `$name` to its expression. `tags:` maps a tag name to a map of thresholds to `$target` modifiers: at that tag count the target gains the value, so sibling thresholds stack. Tag names may be written with or without the leading `#`. YAML resolves bare numbers to numbers and everything else to strings; both become expression text.
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
```csv role=declare
|
```yaml role=declare
|
||||||
tag,threshold,key,expr
|
vars:
|
||||||
,,$hp,$con*5+$mod_hp
|
$hp: $con*5 + $mod_hp
|
||||||
#warrior,2,$mod_hp,20
|
$ac: 10 + $dex
|
||||||
|
tags:
|
||||||
|
warrior:
|
||||||
|
1:
|
||||||
|
$mod_hp: 20
|
||||||
|
2:
|
||||||
|
$mod_str: 1
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
@@ -347,6 +343,7 @@ Used by `:md-dice`, spark-table headers, and the journal `/roll`:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `2d6` | two six-sided dice |
|
| `2d6` | two six-sided dice |
|
||||||
| `1d20+5` | d20 plus modifier |
|
| `1d20+5` | d20 plus modifier |
|
||||||
|
| `d20` | one twenty-sided die (count optional) |
|
||||||
| `3d6kh1` | keep highest 1 |
|
| `3d6kh1` | keep highest 1 |
|
||||||
| `3d6dl2-1` | drop lowest 2, minus 1 |
|
| `3d6dl2-1` | drop lowest 2, minus 1 |
|
||||||
| `{d4, d8}` | choose one of the listed dice |
|
| `{d4, d8}` | choose one of the listed dice |
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
`` ```yaml role=tag `` renders a custom element from YAML. `tag` names the
|
`` ```yaml role=tag `` renders a custom element from YAML. `tag` names the
|
||||||
element, `body` becomes its content, and any other key becomes an attribute.
|
element, `body` becomes its content, and any other key becomes an attribute.
|
||||||
Fence-line attributes override the YAML body. See `reference.md` for the
|
Fence-line attributes override the YAML body; both backtick and tilde (`~~~`)
|
||||||
element and role catalog.
|
fences work. See `reference.md` for the element and role catalog.
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
```yaml role=tag
|
```yaml role=tag
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ npx tttk check ./content
|
|||||||
| 📊 表格 | `:md-table[./data.csv]` | 标签页式表格 |
|
| 📊 表格 | `:md-table[./data.csv]` | 标签页式表格 |
|
||||||
| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局(推荐 `yaml role=tag` 写法) |
|
| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局(推荐 `yaml role=tag` 写法) |
|
||||||
| 📍 标记 | `:md-pins[./map.png]{pins="A:25,50"}` | 图片标记 |
|
| 📍 标记 | `:md-pins[./map.png]{pins="A:25,50"}` | 图片标记 |
|
||||||
| 📋 追踪器 | `:md-commander[./commands.csv]` | 命令历史和状态追踪 |
|
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# Commander Test
|
|
||||||
|
|
||||||
:md-commander[./commands.csv]
|
|
||||||
|
|
||||||
## Lonelog
|
|
||||||
|
|
||||||
`我` 走进酒吧。
|
|
||||||
|
|
||||||
`oracle` 酒吧里人多吗?
|
|
||||||
|
|
||||||
`roll` d20 = 18 `prompt` 是的
|
|
||||||
|
|
||||||
`fact` 酒吧里有很多人。
|
|
||||||
|
|
||||||
:[blah]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
command,parameter,label,description,insertedText
|
|
||||||
roll,emmet,2d6,2D6,2d6
|
|
||||||
roll,emmet,d20,D20,d20
|
|
||||||
track,emmet,monk,破戒佛爷,"破戒佛爷.bandit.monk[gd=4/4,str=14/14,cla=10,spi=8]"
|
|
||||||
track,emmet,bandit,龙虎寨山贼,"龙虎寨山贼.bandit[gd=2/2,str=10/10,cla=10,spi=10]"
|
|
||||||
track,emmet,khitan,契丹护卫,"契丹护卫.khitan.guard[gd=4/4,ap=2,str=14/14,cla=14,spi=14]"
|
|
||||||
|
@@ -33,6 +33,21 @@ bler
|
|||||||
|
|
||||||
:md-table[./npcs.csv]{roll}
|
:md-table[./npcs.csv]{roll}
|
||||||
|
|
||||||
|
```csv role=spark-table
|
||||||
|
2d6,encounter
|
||||||
|
2,Tomato
|
||||||
|
3,Onion
|
||||||
|
4,Cabbage
|
||||||
|
5,Carrot
|
||||||
|
6,Potato
|
||||||
|
7,Leek
|
||||||
|
8,Pumpkin
|
||||||
|
9,Mushroom
|
||||||
|
10,Radish
|
||||||
|
11,Eggplant
|
||||||
|
12,Pepper
|
||||||
|
```
|
||||||
|
|
||||||
### 链接
|
### 链接
|
||||||
|
|
||||||
文本里面可以有:md-link[./stuff.md#啊什么]的啦
|
文本里面可以有:md-link[./stuff.md#啊什么]的啦
|
||||||
|
|||||||
+2
-2
@@ -115,8 +115,8 @@ content/adventures/hollow-mill/adventure.md
|
|||||||
| `TTTK-D002` | error | 属性块畸形(例如未加引号的值中含空格) |
|
| `TTTK-D002` | error | 属性块畸形(例如未加引号的值中含空格) |
|
||||||
| `TTTK-R001` | error | 未知的代码块 `role=`(块内容会被静默丢弃) |
|
| `TTTK-R001` | error | 未知的代码块 `role=`(块内容会被静默丢弃) |
|
||||||
| `TTTK-R002` | warning | `role=spark-table` 首列表头不是骰点公式 |
|
| `TTTK-R002` | warning | `role=spark-table` 首列表头不是骰点公式 |
|
||||||
| `TTTK-R003` | error | `role=declare` 解析失败(缺少 `key`/`expr` 等) |
|
| `TTTK-R003` | error | `role=declare` 解析失败(YAML 非法或结构不符) |
|
||||||
| `TTTK-R004` | warning | `role=declare` 中 `key`/`expr` 为空的行被跳过 |
|
| `TTTK-R004` | warning | `role=declare` 块没有声明任何变量或标签修饰符 |
|
||||||
| `TTTK-R005` | error | `role=tag` 的 YAML 无法解析 |
|
| `TTTK-R005` | error | `role=tag` 的 YAML 无法解析 |
|
||||||
| `TTTK-R006` | error | `role=tag` 缺少 `tag:` 或元素未知 |
|
| `TTTK-R006` | error | `role=tag` 缺少 `tag:` 或元素未知 |
|
||||||
| `TTTK-C001` | error | 内容引用无法解析(文件不存在,或内联 id 未定义) |
|
| `TTTK-C001` | error | 内容引用无法解析(文件不存在,或内联 id 未定义) |
|
||||||
|
|||||||
+1
-2
@@ -46,7 +46,6 @@ tttk/
|
|||||||
│ │ ├── md-table.tsx # 表格组件
|
│ │ ├── md-table.tsx # 表格组件
|
||||||
│ │ ├── md-deck/ # 卡牌组件
|
│ │ ├── md-deck/ # 卡牌组件
|
||||||
│ │ ├── md-pins.tsx # 标记组件
|
│ │ ├── md-pins.tsx # 标记组件
|
||||||
│ │ ├── md-commander/ # 命令追踪器
|
|
||||||
│ │ ├── md-yarn-spinner.tsx # 叙事线组件
|
│ │ ├── md-yarn-spinner.tsx # 叙事线组件
|
||||||
|
|
||||||
│ │ ├── Article.tsx # 文章组件
|
│ │ ├── Article.tsx # 文章组件
|
||||||
@@ -253,7 +252,7 @@ npm test -- --watch
|
|||||||
- Web 前端:rsbuild 开发服务器,端口 **5173**
|
- Web 前端:rsbuild 开发服务器,端口 **5173**
|
||||||
- CLI 内容服务器:`tttk serve`,端口 **3000**(内容 API + Journal)
|
- CLI 内容服务器:`tttk serve`,端口 **3000**(内容 API + Journal)
|
||||||
|
|
||||||
rsbuild 会把 `/__CONTENT_INDEX.json`、`/__COMPLETIONS.json`、`/content`、`/.ttrpg` 代理到 CLI 的
|
rsbuild 会把 `/__CONTENT_INDEX.json`、`/__COMPLETIONS.json`、`/__CONTENT_REGISTRY.json`、`/content`、`/.ttrpg` 代理到 CLI 的
|
||||||
3000 端口,因此必须先启动 CLI,否则前端拿不到内容。
|
3000 端口,因此必须先启动 CLI,否则前端拿不到内容。
|
||||||
|
|
||||||
### Web 开发
|
### Web 开发
|
||||||
|
|||||||
+5
-30
@@ -11,6 +11,10 @@
|
|||||||
- [marked-gfm-heading-id](https://github.com/markedjs/marked-gfm-heading-id) - 标题 ID
|
- [marked-gfm-heading-id](https://github.com/markedjs/marked-gfm-heading-id) - 标题 ID
|
||||||
- [marked-directive](https://github.com/fengzilong/marked-directive) - 指令语法
|
- [marked-directive](https://github.com/fengzilong/marked-directive) - 指令语法
|
||||||
|
|
||||||
|
### 代码块围栏
|
||||||
|
|
||||||
|
代码块围栏可使用反引号或波浪号(`~~~`)书写;闭合围栏必须与开启围栏使用相同字符,且不短于开启围栏。声明 `role=` 的代码块同理。
|
||||||
|
|
||||||
## 指令语法
|
## 指令语法
|
||||||
|
|
||||||
通过 `marked-directive` 支持自定义组件插入:
|
通过 `marked-directive` 支持自定义组件插入:
|
||||||
@@ -123,6 +127,7 @@
|
|||||||
- 点击文本执行掷骰
|
- 点击文本执行掷骰
|
||||||
- 点击骰子图标重置为公式
|
- 点击骰子图标重置为公式
|
||||||
- `key` 属性将结果记录到 URL 参数中 (`?dice-attack=15`)
|
- `key` 属性将结果记录到 URL 参数中 (`?dice-attack=15`)
|
||||||
|
- 骰子数量可省略,`d20` 等价于 `1d20`
|
||||||
|
|
||||||
**属性:**
|
**属性:**
|
||||||
|
|
||||||
@@ -461,36 +466,6 @@ data-config:
|
|||||||
:md-yarn-spinner[./story.yarn]
|
:md-yarn-spinner[./story.yarn]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 📋 命令追踪器 (md-commander)
|
|
||||||
|
|
||||||
支持命令历史和状态追踪。
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
:md-commander
|
|
||||||
```
|
|
||||||
|
|
||||||
**追踪器命令:**
|
|
||||||
|
|
||||||
```
|
|
||||||
track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Emmet 简写语法:**
|
|
||||||
|
|
||||||
- `#id` - 设置 ID
|
|
||||||
- `.class` - 添加类别
|
|
||||||
- `[attr=value]` - 设置属性
|
|
||||||
|
|
||||||
**属性类型:**
|
|
||||||
|
|
||||||
| 类型 | 格式 | 显示 |
|
|
||||||
|------|------|------|
|
|
||||||
| progress | `x/y` | 进度条 |
|
|
||||||
| count | 整数 | 数字计数器 |
|
|
||||||
| string | 文本 | 文本字段 |
|
|
||||||
|
|
||||||
## YAML 标签
|
## YAML 标签
|
||||||
|
|
||||||
使用 ```yaml role=tag 代码块创建自定义标签(`yaml` 语言可被语法高亮):
|
使用 ```yaml role=tag 代码块创建自定义标签(`yaml` 语言可被语法高亮):
|
||||||
|
|||||||
@@ -335,8 +335,6 @@ MCP 服务器提供以下 Resources,包含 TTRPG Tools 的文档和参考材
|
|||||||
- `:md-table` - 表格组件
|
- `:md-table` - 表格组件
|
||||||
- `:md-deck` - 卡牌组件
|
- `:md-deck` - 卡牌组件
|
||||||
- `:md-yarn-spinner` - 叙事线组件
|
- `:md-yarn-spinner` - 叙事线组件
|
||||||
|
|
||||||
- `:md-commander` - 命令追踪器
|
|
||||||
- YAML 标签
|
- YAML 标签
|
||||||
- Mermaid 图表
|
- Mermaid 图表
|
||||||
- 警告/提示块
|
- 警告/提示块
|
||||||
|
|||||||
+16
-19
@@ -54,7 +54,6 @@ Nest with more colons (`::::`, `:::::`).
|
|||||||
| `:md-border` | color or border image | `width`, `slice` |
|
| `:md-border` | color or border image | `width`, `slice` |
|
||||||
| `:md-font` | font family | `source`, `weight`, `color` |
|
| `:md-font` | font family | `source`, `weight`, `color` |
|
||||||
| `:md-pins` | image path | `pins`, `fixed`, `labelStart` |
|
| `:md-pins` | image path | `pins`, `fixed`, `labelStart` |
|
||||||
| `:md-commander` | command template CSV | `placeholder`, `class`, `height` |
|
|
||||||
| `:md-yarn-spinner` | Yarn Spinner file | `start` |
|
| `:md-yarn-spinner` | Yarn Spinner file | `start` |
|
||||||
|
|
||||||
### `:md-dice`
|
### `:md-dice`
|
||||||
@@ -205,19 +204,6 @@ Numbered markers on an image.
|
|||||||
:md-pins[./images/dungeon.png]{labelStart="1"}
|
:md-pins[./images/dungeon.png]{labelStart="1"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `:md-commander`
|
|
||||||
|
|
||||||
Interactive command history / tracker panel.
|
|
||||||
|
|
||||||
The body is a CSV of command templates (`command,parameter,label,description,insertedText`).
|
|
||||||
|
|
||||||
- Body: command template CSV
|
|
||||||
- Attributes: `placeholder`, `class`, `height`
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
:md-commander[./commands.csv]{placeholder="输入命令…" height="400px"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `:md-yarn-spinner`
|
### `:md-yarn-spinner`
|
||||||
|
|
||||||
Runs a Yarn Spinner dialogue file.
|
Runs a Yarn Spinner dialogue file.
|
||||||
@@ -270,6 +256,10 @@ Renders a clickable span that dispatches a journal command.
|
|||||||
The `role=` attribute on the info string determines how a fenced block is
|
The `role=` attribute on the info string determines how a fenced block is
|
||||||
handled.
|
handled.
|
||||||
|
|
||||||
|
Fences may use backticks or tildes (`~~~`); the closing fence must reuse
|
||||||
|
the opening marker. Fence attribute keys may contain hyphens (e.g.
|
||||||
|
`data-foo=bar`).
|
||||||
|
|
||||||
### `role=spark-table`
|
### `role=spark-table`
|
||||||
|
|
||||||
CSV (or markdown pipe table) rendered as `:md-table`.
|
CSV (or markdown pipe table) rendered as `:md-table`.
|
||||||
@@ -297,13 +287,19 @@ d6,结果
|
|||||||
|
|
||||||
Reactive variable / tag-modifier definitions.
|
Reactive variable / tag-modifier definitions.
|
||||||
|
|
||||||
Columns (any order): `tag`, `threshold`, `key`, `expr`. A row with an empty `tag` declares a variable; a row with a `#tag` applies a modifier when that tag is active.
|
YAML with two optional maps. `vars:` maps a `$name` to its expression. `tags:` maps a tag name to a map of thresholds to `$target` modifiers: at that tag count the target gains the value, so sibling thresholds stack. Tag names may be written with or without the leading `#`. YAML resolves bare numbers to numbers and everything else to strings; both become expression text.
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
```csv role=declare
|
```yaml role=declare
|
||||||
tag,threshold,key,expr
|
vars:
|
||||||
,,$hp,$con*5+$mod_hp
|
$hp: $con*5 + $mod_hp
|
||||||
#warrior,2,$mod_hp,20
|
$ac: 10 + $dex
|
||||||
|
tags:
|
||||||
|
warrior:
|
||||||
|
1:
|
||||||
|
$mod_hp: 20
|
||||||
|
2:
|
||||||
|
$mod_str: 1
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
@@ -347,6 +343,7 @@ Used by `:md-dice`, spark-table headers, and the journal `/roll`:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `2d6` | two six-sided dice |
|
| `2d6` | two six-sided dice |
|
||||||
| `1d20+5` | d20 plus modifier |
|
| `1d20+5` | d20 plus modifier |
|
||||||
|
| `d20` | one twenty-sided die (count optional) |
|
||||||
| `3d6kh1` | keep highest 1 |
|
| `3d6kh1` | keep highest 1 |
|
||||||
| `3d6dl2-1` | drop lowest 2, minus 1 |
|
| `3d6dl2-1` | drop lowest 2, minus 1 |
|
||||||
| `{d4, d8}` | choose one of the listed dice |
|
| `{d4, d8}` | choose one of the listed dice |
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Variable store — outstanding fixes
|
||||||
|
|
||||||
|
Notes from an audit of the client-side variable / tag reactivity system:
|
||||||
|
|
||||||
|
- `src/components/journal/var-reactivity.ts` — engine (dependency graph, base/mod
|
||||||
|
separation, threshold-gated tag activation, cascade).
|
||||||
|
- `src/components/journal/variable-expression.ts` — expression evaluator.
|
||||||
|
- `src/components/journal/command-dispatcher.ts` — `/set`, `/rolltag`, cascade.
|
||||||
|
- `src/components/journal/completions.ts` — init + seed.
|
||||||
|
- `src/components/stores/journalStream.ts` — replay + hydration rebuild.
|
||||||
|
- `src/components/journal/types/var.tsx` — `var` message + reducer.
|
||||||
|
|
||||||
|
## Already fixed (context)
|
||||||
|
|
||||||
|
Commit `a26d53c` (`fix(journal): keep variable bases in the stream`):
|
||||||
|
|
||||||
|
- Hydration double-counted active tag modifiers because the stream carried only
|
||||||
|
combined values. `var` messages now carry `base` alongside `value`, the store
|
||||||
|
keeps `variableBases`, and `rebuildReactivityFromStore` restores exact bases.
|
||||||
|
- Seeding ran at import against an empty store (clobbering persisted values and
|
||||||
|
appending duplicate `var` messages on every load). It now runs after hydration
|
||||||
|
and only for keys absent from the store.
|
||||||
|
|
||||||
|
The items below are still open.
|
||||||
|
|
||||||
|
## Open issues
|
||||||
|
|
||||||
|
### 1. Modifier and declaration errors are swallowed — ✅ fixed
|
||||||
|
|
||||||
|
- **Where:** `computeInitialValues` (`catch {}`, "skip failed evaluations at init
|
||||||
|
time") and `applyTagMapActivations` (`catch {}`, "skip failed modifier") in
|
||||||
|
`var-reactivity.ts`; `initReactivity` throws are reduced to `console.warn` in
|
||||||
|
`completions.ts`.
|
||||||
|
- **Symptom:** a typo'd declaration or modifier expression silently yields a
|
||||||
|
wrong value (often `0`) with no author-facing feedback.
|
||||||
|
- **Fixed:** the engine now collects `ReactivityDiagnostic` entries for failed
|
||||||
|
declarations (`computeInitialValues`, `reevaluateDependents`), failed modifier
|
||||||
|
expressions (`applyTagMapActivations`), and circular declarations
|
||||||
|
(`initReactivity`). They are deduped, logged via `console.warn` with
|
||||||
|
key/expression/tag context, cleared on each `initReactivity`, and bridged into
|
||||||
|
a signal exposed as `useJournalCompletions().diagnostics()`. `VariableView`
|
||||||
|
renders them as a warning banner. A bad declaration no longer aborts the rest
|
||||||
|
of a cascade.
|
||||||
|
|
||||||
|
### 2. `var del` does not reach the engine — ✅ fixed (dropped)
|
||||||
|
|
||||||
|
- **Where:** reducer in `types/var.tsx` supports `action: "del"`; the parser
|
||||||
|
(`command-parser.ts`) has no `/del`; `var-reactivity.ts` never clears
|
||||||
|
`baseValues` and `rebuildReactivityFromStore` never removes stale keys.
|
||||||
|
- **Symptom:** a deleted variable still resolves locally via `getCombined`,
|
||||||
|
because it reads `baseValues` first.
|
||||||
|
- **Fixed:** `/del` was never reachable (no parser emits it, and replay does not
|
||||||
|
validate), so the `del` action was removed from the `var` schema, render, and
|
||||||
|
reducer. The wire type is now `action: "set"` only; a legacy `del` message in
|
||||||
|
an old stream is a no-op on replay rather than resurrecting a deleted key.
|
||||||
|
|
||||||
|
### 3. Reactivity is initialized once, at import — ✅ fixed
|
||||||
|
|
||||||
|
- **Where:** `bootstrapReactivity` in `completions.ts` runs `initReactivity`
|
||||||
|
once; nothing re-runs it when declare blocks change or a session/document is
|
||||||
|
switched.
|
||||||
|
- **Also:** `DispatchContext.declarations` / `tagModifiers`
|
||||||
|
(`command-dispatcher.ts`) are declared but never read — a vestige of a
|
||||||
|
per-dispatch init that was never wired up.
|
||||||
|
- **Symptom:** editing `role=declare` content requires a full reload; stale
|
||||||
|
graph/expressions persist.
|
||||||
|
- **Fixed:** `bootstrapReactivity` is now `applyReactivity` behind an idempotent
|
||||||
|
`ensureReactivity()` that reads the live completions signal, compares a
|
||||||
|
signature, and re-initializes only when the declaration set changes.
|
||||||
|
`dispatchCommand` calls it before every dispatch, so a content rescan takes
|
||||||
|
effect on the next command without a reload. The unused
|
||||||
|
`DispatchContext.declarations` / `tagModifiers` fields were removed (they were
|
||||||
|
mount-time snapshots and could not be trusted). Session switches were already
|
||||||
|
covered by the hydration rebuild, which now also passes
|
||||||
|
`journalStreamState.variableBases` so bases are restored exactly instead of
|
||||||
|
falling back to combined values.
|
||||||
|
|
||||||
|
### 4. `lookup` contract disagrees with its docs — ✅ fixed
|
||||||
|
|
||||||
|
- **Where:** `EvalContext.lookup` documents "Resolve `$var`", but
|
||||||
|
`parseFactor` calls `ctx.lookup(varName.slice(1))` (bare name), and every
|
||||||
|
caller re-adds `$`.
|
||||||
|
- **Symptom:** a caller following the doc would key variables with `$` and
|
||||||
|
silently get `0`s.
|
||||||
|
- **Fixed:** `EvalContext.lookup` now receives the full `$var` name (including
|
||||||
|
`$`), consistent with every other variable API (`getCombined`, `setBase`,
|
||||||
|
`getMods`, `resolveVariable`). Callers no longer add or strip `$`.
|
||||||
|
|
||||||
|
### 5. Undefined variables evaluate to `0` — ✅ fixed (strict for commands)
|
||||||
|
|
||||||
|
- **Where:** `parseFactor` (`variable-expression.ts`) returns a numeric `0` for
|
||||||
|
an unresolved `$var`.
|
||||||
|
- **Symptom:** `/set $hp $cn*5` (typo) silently sets `0` instead of failing.
|
||||||
|
- **Fixed:** `parseFactor` now throws `Unknown variable: $x` whenever `lookup`
|
||||||
|
returns `undefined`, and `resolveVariable()` distinguishes unknown from a real
|
||||||
|
`0`. `/set` and `/roll` use the strict resolver, so typos fail loudly via
|
||||||
|
`dispatchError`. Declarations and tag modifiers stay lenient on purpose: they
|
||||||
|
resolve unknown references to `0` through `getCombined`, because the
|
||||||
|
documented pattern (`$hp = $con*5+$mod_hp`) relies on external/undeclared
|
||||||
|
variables defaulting to 0.
|
||||||
|
|
||||||
|
### 6. Tokenizer accepts malformed numbers — ✅ fixed
|
||||||
|
|
||||||
|
- **Where:** the number branch of `tokenize` consumes `[0-9.]` greedily, so
|
||||||
|
`1.2.3` becomes one "number" and `parseFloat` truncates it.
|
||||||
|
- **Fixed:** the tokenizer now validates the scanned chunk against
|
||||||
|
`^\d+(\.\d+)?$` and throws `Invalid number literal: "…"` instead of pushing a
|
||||||
|
silently-truncated value. The error propagates through the diagnostics channel
|
||||||
|
(#1) for declarations/modifiers and through `dispatchError` for `/set` and
|
||||||
|
`/roll`.
|
||||||
|
|
||||||
|
### 7. `topoSortAffected` re-evaluates untouched declarations — ✅ fixed
|
||||||
|
|
||||||
|
- **Where:** in `var-reactivity.ts`, the visit condition
|
||||||
|
`affected.has(dep) || declExprs.has(dep)` pulls declared dependencies that are
|
||||||
|
not in `affected` into the result.
|
||||||
|
- **Symptom:** `reevaluateDependents` re-evaluates and re-publishes variables
|
||||||
|
that did not change. Mostly harmless, but noisy — and a re-evaluated
|
||||||
|
declaration could overwrite a value that had just been `/set`.
|
||||||
|
- **Fixed:** the visit condition now only follows dependencies in `affected`,
|
||||||
|
so a cascade re-evaluates exactly the changed variable's dependents.
|
||||||
|
|
||||||
|
### 8. Failed `initReactivity` leaves partial state — ✅ fixed
|
||||||
|
|
||||||
|
- **Where:** `initReactivity` populates `depGraph` / `declExprs` / `tagModMap`
|
||||||
|
before `checkCircular()` throws; `inFlight` is never cleared on re-init.
|
||||||
|
- **Fixed:** `initReactivity` now builds the declaration map, dependency graph,
|
||||||
|
and tag-modifier map into locals and commits them only after `checkCircular`
|
||||||
|
passes, so a failed (re-)init leaves the previous engine intact. Runtime state
|
||||||
|
(`baseValues`, mods, `sourceActivations`) and `inFlight` are reset on commit.
|
||||||
|
|
||||||
|
### 9. Residual base / tagmap-target edges — low
|
||||||
|
|
||||||
|
- **Base fallback:** `rebuildReactivityFromStore` falls back to the combined
|
||||||
|
`value` when a message has no `base`, which double-counts mods. In-repo
|
||||||
|
emitters always send `base`; only hand-crafted/external `var` messages hit
|
||||||
|
this.
|
||||||
|
- **Tagmap targets:** rebuild derives activations from each source's *base*
|
||||||
|
tagmap (this fixed a feedback loop where a tagmap-valued target re-triggered
|
||||||
|
its own modifier on every rebuild). If a variable's effective count crosses a
|
||||||
|
threshold purely via mods, that activation is not reconstructed on hydration.
|
||||||
|
Revisit if tagmap-valued modifier targets come into use.
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
1. #3 (re-init) and #2 (`del`) — correctness of engine lifecycle.
|
||||||
|
2. #1 (diagnostics) — authoring feedback.
|
||||||
|
3. #4–#8 — cleanup and hardening.
|
||||||
|
4. #9 — only if the tagmap-target pattern is adopted.
|
||||||
@@ -55,6 +55,7 @@ export default defineConfig({
|
|||||||
// Proxy content API requests to the CLI dev server (npm run tttk)
|
// Proxy content API requests to the CLI dev server (npm run tttk)
|
||||||
"/__CONTENT_INDEX.json": "http://localhost:3000",
|
"/__CONTENT_INDEX.json": "http://localhost:3000",
|
||||||
"/__COMPLETIONS.json": "http://localhost:3000",
|
"/__COMPLETIONS.json": "http://localhost:3000",
|
||||||
|
"/__CONTENT_REGISTRY.json": "http://localhost:3000",
|
||||||
"/content": "http://localhost:3000",
|
"/content": "http://localhost:3000",
|
||||||
"/.ttrpg": "http://localhost:3000",
|
"/.ttrpg": "http://localhost:3000",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,5 +22,9 @@ export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js"
|
|||||||
// Regex
|
// Regex
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Matches fenced code blocks with an info string (at least one attr). */
|
/**
|
||||||
export const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm;
|
* Matches fenced code blocks with an info string (at least one attr).
|
||||||
|
* Supports both backtick and tilde fences; the closing fence reuses the
|
||||||
|
* opening marker character and must be at least as long.
|
||||||
|
*/
|
||||||
|
export const FENCED_BLOCK_RE = /^([`~])\1{2,}\s*(\w*)\s+(\S.*?)\s*\n([\s\S]*?)\1{3,}\s*$/gm;
|
||||||
@@ -1,21 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* Declare parser — parses ```csv role=declare code blocks from markdown.
|
* Declare parser — parses ```yaml role=declare code blocks from markdown.
|
||||||
*
|
*
|
||||||
* Shared between CLI completions scanner and browser-side completions.
|
* Shared between the CLI scanner/validator and the browser-side journal.
|
||||||
*
|
*
|
||||||
* Format (columns can be in any order; `tag` and `threshold` are optional):
|
* The body is YAML with two optional maps (`declareSchema` below):
|
||||||
* tag,threshold,key,expr
|
|
||||||
* ,,$hp,$con*5+$mod_hp ← variable declaration
|
|
||||||
* ,,$ac,10+$dex ← variable declaration
|
|
||||||
* #warrior,2,$mod_hp,20 ← tag modifier (threshold 2)
|
|
||||||
* #warrior,,$mod_str,1 ← tag modifier (threshold defaults to 1)
|
|
||||||
*
|
*
|
||||||
* When `tag` is empty: $key is a reactively computed variable.
|
* vars:
|
||||||
* When `tag` is present: when #tag met (source's tagmap value >= threshold),
|
* $hp: $con*5 + $mod_hp
|
||||||
* $key gets expr added to its base.
|
* $ac: 10 + $dex
|
||||||
|
* tags:
|
||||||
|
* warrior:
|
||||||
|
* 1:
|
||||||
|
* $mod_hp: 20
|
||||||
|
* 2:
|
||||||
|
* $mod_str: 1
|
||||||
|
*
|
||||||
|
* `vars` entries are reactively computed variables. `tags` entries are
|
||||||
|
* modifiers: when a source variable's tag map holds `#warrior` at a count
|
||||||
|
* >= the threshold key, each `$target` gains the modifier value. Tag names
|
||||||
|
* may be written with or without the leading `#`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parse } from "csv-parse/browser/esm/sync";
|
import yaml from "js-yaml";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -38,65 +45,134 @@ export interface DeclareResult {
|
|||||||
tagModifiers: TagModifier[];
|
tagModifiers: TagModifier[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schema
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A `$name` variable key. */
|
||||||
|
const VAR_NAME = /^\$[A-Za-z_][A-Za-z0-9_]*$/;
|
||||||
|
/** A tag name, optionally carrying its leading `#`. */
|
||||||
|
const TAG_NAME = /^#?[A-Za-z_][A-Za-z0-9_-]*$/;
|
||||||
|
/** Threshold map keys are positive integers (1, 2, 3, …). */
|
||||||
|
const THRESHOLD = /^[1-9][0-9]*$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a single ```csv role=declare block body.
|
* Expression text. YAML resolves bare numbers to `number`, so both strings and
|
||||||
|
* numbers are accepted and normalized to a trimmed string. A missing value
|
||||||
|
* loads as `null` and becomes empty text, which the refine rejects. Anything
|
||||||
|
* else (boolean, date) is a mistake in the source. Unknown references inside
|
||||||
|
* the expression are resolved by the reactivity engine, not here.
|
||||||
*/
|
*/
|
||||||
export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
const exprSchema = z
|
||||||
const trimmed = csv.trim();
|
.union([z.string(), z.number(), z.null()])
|
||||||
|
.transform((value) => (value === null ? "" : String(value).trim()))
|
||||||
|
.refine((value) => value.length > 0, "expression must not be empty");
|
||||||
|
|
||||||
|
/** A map whose keys must look like `$name`. */
|
||||||
|
function varMap(message: string) {
|
||||||
|
return z.record(z.string(), exprSchema).superRefine((obj, ctx) => {
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
if (!VAR_NAME.test(key)) {
|
||||||
|
ctx.addIssue({ code: "custom", message, path: [key] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `threshold -> { $target: expression }`. */
|
||||||
|
const thresholdMap = z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
varMap("tag modifier targets must start with $, e.g. `$mod_hp`"),
|
||||||
|
)
|
||||||
|
.superRefine((obj, ctx) => {
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
if (!THRESHOLD.test(key)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "threshold must be a positive integer",
|
||||||
|
path: [key],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The accepted `role=declare` shape. Exported for tests and reuse. */
|
||||||
|
export const declareSchema = z.strictObject({
|
||||||
|
vars: varMap("variable names must start with $, e.g. `$hp`").optional(),
|
||||||
|
tags: z
|
||||||
|
.record(z.string(), thresholdMap)
|
||||||
|
.superRefine((obj, ctx) => {
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
if (!TAG_NAME.test(key)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "tag names may not contain spaces or punctuation",
|
||||||
|
path: [key],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single ```yaml role=declare block body.
|
||||||
|
*
|
||||||
|
* Throws with a `path: message` summary when the body is not valid YAML or
|
||||||
|
* does not match the schema. Callers attach the file/line context.
|
||||||
|
*/
|
||||||
|
export function parseDeclare(body: string): DeclareResult {
|
||||||
|
const trimmed = body.trim();
|
||||||
if (!trimmed) return { variables: [], tagModifiers: [] };
|
if (!trimmed) return { variables: [], tagModifiers: [] };
|
||||||
|
|
||||||
// Validate that required columns exist (order-agnostic, tag is optional)
|
let data: unknown;
|
||||||
const firstLine = trimmed.split(/\r?\n/)[0];
|
try {
|
||||||
const headers = firstLine.split(",").map((h) => h.trim().toLowerCase());
|
data = yaml.load(trimmed);
|
||||||
if (!headers.includes("key") || !headers.includes("expr")) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${source}: role=declare blocks must have "key" and "expr" columns. Got: ${headers.join(",")}`,
|
`role=declare body is not valid YAML: ${errorText(error)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = parse(trimmed, {
|
const parsed = declareSchema.safeParse(data ?? {});
|
||||||
columns: true,
|
if (!parsed.success) {
|
||||||
trim: true,
|
throw new Error(`role=declare: ${formatIssues(parsed.error)}`);
|
||||||
skipEmptyLines: true,
|
}
|
||||||
}) as Array<{ tag?: string; threshold?: string; key: string; expr: string }>;
|
|
||||||
|
|
||||||
const variables: VarDeclaration[] = [];
|
const variables: VarDeclaration[] = [];
|
||||||
|
for (const [key, expression] of Object.entries(parsed.data.vars ?? {})) {
|
||||||
|
variables.push({ key, expression });
|
||||||
|
}
|
||||||
|
|
||||||
const tagModifiers: TagModifier[] = [];
|
const tagModifiers: TagModifier[] = [];
|
||||||
|
for (const [name, thresholds] of Object.entries(parsed.data.tags ?? {})) {
|
||||||
for (const row of records) {
|
const tag = name.startsWith("#") ? name : `#${name}`;
|
||||||
const tag = row.tag ?? "";
|
for (const [rawThreshold, targets] of Object.entries(thresholds)) {
|
||||||
const key = row.key ?? "";
|
const threshold = Number(rawThreshold);
|
||||||
const expr = row.expr ?? "";
|
for (const [target, expression] of Object.entries(targets)) {
|
||||||
|
tagModifiers.push({ tag, target, expression, threshold });
|
||||||
if (!key || !expr) {
|
|
||||||
console.warn(`${source}: skipping row with empty key or expr`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tag) {
|
|
||||||
// Tag modifier
|
|
||||||
if (!tag.startsWith("#")) {
|
|
||||||
throw new Error(`${source}: tag must start with #, got "${tag}"`);
|
|
||||||
}
|
}
|
||||||
if (!key.startsWith("$")) {
|
|
||||||
throw new Error(`${source}: key must start with $, got "${key}"`);
|
|
||||||
}
|
|
||||||
const thresholdRaw = row.threshold?.trim() ?? "";
|
|
||||||
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
|
|
||||||
if (isNaN(threshold) || threshold < 1) {
|
|
||||||
throw new Error(
|
|
||||||
`${source}: threshold must be a positive integer, got "${row.threshold}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
tagModifiers.push({ tag, target: key, expression: expr, threshold });
|
|
||||||
} else {
|
|
||||||
// Variable declaration
|
|
||||||
if (!key.startsWith("$")) {
|
|
||||||
throw new Error(`${source}: key must start with $, got "${key}"`);
|
|
||||||
}
|
|
||||||
variables.push({ key, expression: expr });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { variables, tagModifiers };
|
return { variables, tagModifiers };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatIssues(error: z.ZodError): string {
|
||||||
|
return error.issues
|
||||||
|
.map((issue) =>
|
||||||
|
issue.path.length
|
||||||
|
? `${issue.path.join(".")}: ${issue.message}`
|
||||||
|
: issue.message,
|
||||||
|
)
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for the variable/tag system:
|
* Tests for the variable/tag system:
|
||||||
* - declare-parser (CSV parsing)
|
* - declare-parser (YAML parsing)
|
||||||
* - block-scanner (attribute parsing)
|
* - block-scanner (attribute parsing)
|
||||||
* - variable-expression (expression evaluation)
|
* - variable-expression (expression evaluation)
|
||||||
* - var-reactivity (dependency graph, cascade, tag activation)
|
* - var-reactivity (dependency graph, cascade, tag activation)
|
||||||
@@ -27,7 +27,7 @@ jest.mock("github-slugger", () => {
|
|||||||
// Imports
|
// Imports
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import { parseDeclareCsv } from "./declare-parser";
|
import { parseDeclare } from "./declare-parser";
|
||||||
import { parseBlockAttrs } from "./block-scanner";
|
import { parseBlockAttrs } from "./block-scanner";
|
||||||
import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
|
import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +39,9 @@ import {
|
|||||||
getMods,
|
getMods,
|
||||||
getDeclExpr,
|
getDeclExpr,
|
||||||
extractDependencies,
|
extractDependencies,
|
||||||
|
rebuildReactivityFromStore,
|
||||||
|
getReactivityDiagnostics,
|
||||||
|
resolveVariable,
|
||||||
} from "../../components/journal/var-reactivity";
|
} from "../../components/journal/var-reactivity";
|
||||||
import { parseInput } from "../../components/journal/command-parser";
|
import { parseInput } from "../../components/journal/command-parser";
|
||||||
import {
|
import {
|
||||||
@@ -60,154 +63,154 @@ function store(entries: Record<string, string> = {}): Record<string, string> {
|
|||||||
// declare-parser
|
// declare-parser
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("parseDeclareCsv", () => {
|
describe("parseDeclare", () => {
|
||||||
test("parses variable declarations", () => {
|
test("parses variable declarations", () => {
|
||||||
const csv = `tag,key,expr
|
const result = parseDeclare(`
|
||||||
,$hp,$con*5+$mod_hp
|
vars:
|
||||||
,$ac,10+$dex`;
|
$hp: $con*5 + $mod_hp
|
||||||
const result = parseDeclareCsv(csv, "test.md");
|
$ac: 10 + $dex
|
||||||
expect(result.variables).toHaveLength(2);
|
`);
|
||||||
expect(result.variables[0]).toEqual({
|
expect(result.variables).toEqual([
|
||||||
key: "$hp",
|
{ key: "$hp", expression: "$con*5 + $mod_hp" },
|
||||||
expression: "$con*5+$mod_hp",
|
{ key: "$ac", expression: "10 + $dex" },
|
||||||
});
|
]);
|
||||||
expect(result.variables[1]).toEqual({ key: "$ac", expression: "10+$dex" });
|
|
||||||
expect(result.tagModifiers).toHaveLength(0);
|
expect(result.tagModifiers).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses tag modifiers", () => {
|
test("parses tag modifiers with thresholds", () => {
|
||||||
const csv = `tag,key,expr
|
const result = parseDeclare(`
|
||||||
#warrior,$mod_hp,20
|
tags:
|
||||||
#mage,$mod_mp,15`;
|
warrior:
|
||||||
const result = parseDeclareCsv(csv, "test.md");
|
2:
|
||||||
|
$mod_hp: 20
|
||||||
|
mage:
|
||||||
|
1:
|
||||||
|
$mod_mp: 15
|
||||||
|
`);
|
||||||
expect(result.variables).toHaveLength(0);
|
expect(result.variables).toHaveLength(0);
|
||||||
expect(result.tagModifiers).toHaveLength(2);
|
expect(result.tagModifiers).toEqual([
|
||||||
|
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 },
|
||||||
|
{ tag: "#mage", target: "$mod_mp", expression: "15", threshold: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple thresholds for one tag stack in ascending order", () => {
|
||||||
|
const result = parseDeclare(`
|
||||||
|
tags:
|
||||||
|
warrior:
|
||||||
|
3:
|
||||||
|
$mod_hp: 20
|
||||||
|
1:
|
||||||
|
$mod_hp: 10
|
||||||
|
`);
|
||||||
|
expect(result.tagModifiers).toEqual([
|
||||||
|
{ tag: "#warrior", target: "$mod_hp", expression: "10", threshold: 1 },
|
||||||
|
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one threshold can carry multiple targets", () => {
|
||||||
|
const result = parseDeclare(`
|
||||||
|
tags:
|
||||||
|
warrior:
|
||||||
|
1:
|
||||||
|
$mod_hp: 20
|
||||||
|
$mod_str: 1
|
||||||
|
`);
|
||||||
|
expect(result.tagModifiers).toEqual([
|
||||||
|
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
||||||
|
{ tag: "#warrior", target: "$mod_str", expression: "1", threshold: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a tag name written with #", () => {
|
||||||
|
const result = parseDeclare('tags:\n "#warrior":\n 1:\n $mod_hp: 20\n');
|
||||||
expect(result.tagModifiers[0]).toEqual({
|
expect(result.tagModifiers[0]).toEqual({
|
||||||
tag: "#warrior",
|
tag: "#warrior",
|
||||||
target: "$mod_hp",
|
target: "$mod_hp",
|
||||||
expression: "20",
|
expression: "20",
|
||||||
threshold: 1,
|
threshold: 1,
|
||||||
});
|
});
|
||||||
expect(result.tagModifiers[1]).toEqual({
|
|
||||||
tag: "#mage",
|
|
||||||
target: "$mod_mp",
|
|
||||||
expression: "15",
|
|
||||||
threshold: 1,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses tag modifiers with threshold", () => {
|
test("normalizes numeric values to expression text", () => {
|
||||||
const csv = `tag,threshold,key,expr
|
const result = parseDeclare("vars:\n $con: 10\n $dex: 12\n");
|
||||||
#warrior,2,$mod_hp,20
|
expect(result.variables).toEqual([
|
||||||
#mage,,$mod_mp,15`;
|
{ key: "$con", expression: "10" },
|
||||||
const result = parseDeclareCsv(csv, "test.md");
|
{ key: "$dex", expression: "12" },
|
||||||
expect(result.tagModifiers).toHaveLength(2);
|
]);
|
||||||
expect(result.tagModifiers[0]).toEqual({
|
|
||||||
tag: "#warrior",
|
|
||||||
target: "$mod_hp",
|
|
||||||
expression: "20",
|
|
||||||
threshold: 2,
|
|
||||||
});
|
|
||||||
expect(result.tagModifiers[1]).toEqual({
|
|
||||||
tag: "#mage",
|
|
||||||
target: "$mod_mp",
|
|
||||||
expression: "15",
|
|
||||||
threshold: 1,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects invalid threshold", () => {
|
test("YAML numeric forms are resolved before stringification", () => {
|
||||||
const csv = `tag,threshold,key,expr
|
const result = parseDeclare("vars:\n $a: 0x1F\n $b: 1e3\n");
|
||||||
#warrior,0,$mod_hp,20`;
|
expect(result.variables).toEqual([
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
{ key: "$a", expression: "31" },
|
||||||
"threshold must be a positive integer",
|
{ key: "$b", expression: "1000" },
|
||||||
);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses mixed declarations and tag modifiers", () => {
|
test("parses mixed declarations and tag modifiers", () => {
|
||||||
const csv = `tag,key,expr
|
const result = parseDeclare(`
|
||||||
,$hp,$con*5
|
vars:
|
||||||
#warrior,$mod_hp,20
|
$hp: $con*5
|
||||||
,$ac,10+$dex`;
|
$ac: 10+$dex
|
||||||
const result = parseDeclareCsv(csv, "test.md");
|
tags:
|
||||||
|
warrior:
|
||||||
|
1:
|
||||||
|
$mod_hp: 20
|
||||||
|
`);
|
||||||
expect(result.variables).toHaveLength(2);
|
expect(result.variables).toHaveLength(2);
|
||||||
expect(result.tagModifiers).toHaveLength(1);
|
expect(result.tagModifiers).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects missing key column", () => {
|
test("handles empty, whitespace, and comment-only bodies", () => {
|
||||||
const csv = `tag,expr
|
for (const body of ["", " \n ", "# just a comment\n"]) {
|
||||||
,$con*5`;
|
expect(parseDeclare(body)).toEqual({ variables: [], tagModifiers: [] });
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
}
|
||||||
'role=declare blocks must have "key" and "expr" columns',
|
});
|
||||||
|
|
||||||
|
test("rejects invalid YAML", () => {
|
||||||
|
expect(() => parseDeclare("vars: [unclosed\n")).toThrow("not valid YAML");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects duplicate keys", () => {
|
||||||
|
expect(() => parseDeclare("vars:\n $hp: 1\n $hp: 2\n")).toThrow(
|
||||||
|
"duplicated mapping key",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects missing expr column", () => {
|
test("rejects unknown top-level keys", () => {
|
||||||
const csv = `tag,key
|
expect(() => parseDeclare("variable:\n $hp: 1\n")).toThrow(
|
||||||
,$hp`;
|
"Unrecognized key",
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
|
||||||
'role=declare blocks must have "key" and "expr" columns',
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects tag without # prefix", () => {
|
test("rejects a variable name without $", () => {
|
||||||
const csv = `tag,key,expr
|
expect(() => parseDeclare("vars:\n hp: 1\n")).toThrow(
|
||||||
warrior,$mod_hp,20`;
|
"variable names must start with $",
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
|
||||||
"tag must start with #",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects key without $ prefix in declaration", () => {
|
test("rejects a tag modifier target without $", () => {
|
||||||
const csv = `tag,key,expr
|
expect(() =>
|
||||||
,hp,$con*5`;
|
parseDeclare("tags:\n warrior:\n 1:\n mod_hp: 20\n"),
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
).toThrow("tag modifier targets must start with $");
|
||||||
"key must start with $",
|
});
|
||||||
|
|
||||||
|
test("rejects a threshold that is not a positive integer", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseDeclare("tags:\n warrior:\n 0:\n $mod_hp: 20\n"),
|
||||||
|
).toThrow("threshold must be a positive integer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an empty expression", () => {
|
||||||
|
expect(() => parseDeclare("vars:\n $hp:\n")).toThrow(
|
||||||
|
"expression must not be empty",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects key without $ prefix in tag modifier", () => {
|
test("rejects non-numeric, non-string values", () => {
|
||||||
const csv = `tag,key,expr
|
expect(() => parseDeclare("vars:\n $hp: true\n")).toThrow();
|
||||||
#warrior,mod_hp,20`;
|
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
|
||||||
"key must start with $",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("skips rows with empty key or expr (warns, doesn't throw)", () => {
|
|
||||||
const csv = `tag,key,expr
|
|
||||||
,$hp,$con*5
|
|
||||||
,,`;
|
|
||||||
const result = parseDeclareCsv(csv, "test.md");
|
|
||||||
expect(result.variables).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles empty CSV", () => {
|
|
||||||
const result = parseDeclareCsv("", "test.md");
|
|
||||||
expect(result.variables).toHaveLength(0);
|
|
||||||
expect(result.tagModifiers).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles whitespace-only CSV", () => {
|
|
||||||
const result = parseDeclareCsv(" \n ", "test.md");
|
|
||||||
expect(result.variables).toHaveLength(0);
|
|
||||||
expect(result.tagModifiers).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("columns can be in any order", () => {
|
|
||||||
const csv = `expr,key,tag
|
|
||||||
$con*5,$hp,
|
|
||||||
20,$mod_hp,#warrior`;
|
|
||||||
const result = parseDeclareCsv(csv, "test.md");
|
|
||||||
expect(result.variables).toHaveLength(1);
|
|
||||||
expect(result.variables[0]).toEqual({ key: "$hp", expression: "$con*5" });
|
|
||||||
expect(result.tagModifiers).toHaveLength(1);
|
|
||||||
expect(result.tagModifiers[0]).toEqual({
|
|
||||||
tag: "#warrior",
|
|
||||||
target: "$mod_hp",
|
|
||||||
expression: "20",
|
|
||||||
threshold: 1,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,16 +229,26 @@ describe("parseBlockAttrs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("parses quoted values", () => {
|
test("parses quoted values", () => {
|
||||||
const attrs = parseBlockAttrs('csv id="my stats" role=declare');
|
const attrs = parseBlockAttrs('yaml id="my stats" role=declare');
|
||||||
expect(attrs.id).toBe("my stats");
|
expect(attrs.id).toBe("my stats");
|
||||||
expect(attrs.role).toBe("declare");
|
expect(attrs.role).toBe("declare");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("collects unknown attributes in extra", () => {
|
test("collects unknown attributes in extra", () => {
|
||||||
const attrs = parseBlockAttrs("csv role=declare foo=bar baz=42");
|
const attrs = parseBlockAttrs("yaml role=declare foo=bar baz=42");
|
||||||
expect(attrs.extra).toEqual({ foo: "bar", baz: "42" });
|
expect(attrs.extra).toEqual({ foo: "bar", baz: "42" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps hyphenated attribute names intact", () => {
|
||||||
|
const attrs = parseBlockAttrs("yaml role=tag data-config=foo data-src=./x");
|
||||||
|
expect(attrs.extra).toEqual({ "data-config": "foo", "data-src": "./x" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps hyphenated attribute names with quoted values", () => {
|
||||||
|
const attrs = parseBlockAttrs('yaml role=tag data-config="my config"');
|
||||||
|
expect(attrs.extra).toEqual({ "data-config": "my config" });
|
||||||
|
});
|
||||||
|
|
||||||
test("handles empty info string", () => {
|
test("handles empty info string", () => {
|
||||||
const attrs = parseBlockAttrs("");
|
const attrs = parseBlockAttrs("");
|
||||||
expect(attrs.lang).toBe("");
|
expect(attrs.lang).toBe("");
|
||||||
@@ -271,32 +284,31 @@ describe("evaluateExpression", () => {
|
|||||||
test("evaluates variable references", () => {
|
test("evaluates variable references", () => {
|
||||||
const result = evaluateExpression("$con * 5 + $mod_hp", {
|
const result = evaluateExpression("$con * 5 + $mod_hp", {
|
||||||
lookup: (name) => {
|
lookup: (name) => {
|
||||||
if (name === "con") return "12";
|
if (name === "$con") return "12";
|
||||||
if (name === "mod_hp") return "20";
|
if (name === "$mod_hp") return "20";
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.value).toEqual({ kind: "number", value: 80 }); // 12*5 + 20
|
expect(result.value).toEqual({ kind: "number", value: 80 }); // 12*5 + 20
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns 0 for undefined variables", () => {
|
test("throws on unknown variables", () => {
|
||||||
const result = evaluateExpression("$unknown + 5", {
|
expect(() =>
|
||||||
lookup: () => undefined,
|
evaluateExpression("$unknown + 5", { lookup: () => undefined }),
|
||||||
});
|
).toThrow("Unknown variable: $unknown");
|
||||||
expect(result.value).toEqual({ kind: "number", value: 5 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws on type mismatch (tagmap + number)", () => {
|
test("throws on type mismatch (tagmap + number)", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
evaluateExpression("$class + 5", {
|
evaluateExpression("$class + 5", {
|
||||||
lookup: (name) => (name === "class" ? "#warrior" : undefined),
|
lookup: (name) => (name === "$class" ? "#warrior" : undefined),
|
||||||
}),
|
}),
|
||||||
).toThrow("Type mismatch");
|
).toThrow("Type mismatch");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolves tagmap variable without arithmetic", () => {
|
test("resolves tagmap variable without arithmetic", () => {
|
||||||
const result = evaluateExpression("$class", {
|
const result = evaluateExpression("$class", {
|
||||||
lookup: (name) => (name === "class" ? "#warrior:1" : undefined),
|
lookup: (name) => (name === "$class" ? "#warrior:1" : undefined),
|
||||||
});
|
});
|
||||||
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } });
|
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } });
|
||||||
});
|
});
|
||||||
@@ -377,6 +389,20 @@ describe("evaluateExpression", () => {
|
|||||||
expect(result.value.value).toBeLessThanOrEqual(23);
|
expect(result.value.value).toBeLessThanOrEqual(23);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("evaluates bare dice notation", () => {
|
||||||
|
const result = evaluateExpression("d20", { lookup: () => undefined });
|
||||||
|
expect(result.value.kind).toBe("number");
|
||||||
|
expect(result.value.value).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(result.value.value).toBeLessThanOrEqual(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evaluates bare dice alongside modifiers", () => {
|
||||||
|
const result = evaluateExpression("d6 + 5", { lookup: () => undefined });
|
||||||
|
expect(result.value.kind).toBe("number");
|
||||||
|
expect(result.value.value).toBeGreaterThanOrEqual(6);
|
||||||
|
expect(result.value.value).toBeLessThanOrEqual(11);
|
||||||
|
});
|
||||||
|
|
||||||
test("throws on division by zero", () => {
|
test("throws on division by zero", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
evaluateExpression("10 / 0", { lookup: () => undefined }),
|
evaluateExpression("10 / 0", { lookup: () => undefined }),
|
||||||
@@ -395,6 +421,15 @@ describe("evaluateExpression", () => {
|
|||||||
).toThrow("Unexpected end");
|
).toThrow("Unexpected end");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("throws on malformed number literals", () => {
|
||||||
|
expect(() =>
|
||||||
|
evaluateExpression("1.2.3", { lookup: () => undefined }),
|
||||||
|
).toThrow("Invalid number literal");
|
||||||
|
expect(() =>
|
||||||
|
evaluateExpression("1.", { lookup: () => undefined }),
|
||||||
|
).toThrow("Invalid number literal");
|
||||||
|
});
|
||||||
|
|
||||||
test("handles decimal numbers", () => {
|
test("handles decimal numbers", () => {
|
||||||
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined });
|
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined });
|
||||||
expect(result.value).toEqual({ kind: "number", value: 6 });
|
expect(result.value).toEqual({ kind: "number", value: 6 });
|
||||||
@@ -411,8 +446,8 @@ describe("evaluateExpression", () => {
|
|||||||
test("complex expression with variables and functions", () => {
|
test("complex expression with variables and functions", () => {
|
||||||
const result = evaluateExpression("floor($con / 2) + $mod", {
|
const result = evaluateExpression("floor($con / 2) + $mod", {
|
||||||
lookup: (name) => {
|
lookup: (name) => {
|
||||||
if (name === "con") return "15";
|
if (name === "$con") return "15";
|
||||||
if (name === "mod") return "3";
|
if (name === "$mod") return "3";
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -506,6 +541,33 @@ describe("var-reactivity", () => {
|
|||||||
}),
|
}),
|
||||||
).not.toThrow();
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps the previous configuration when a re-init fails", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
initReactivity({
|
||||||
|
declarations: [{ key: "$hp", expression: "10 + 5" }],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
expect(computeInitialValues(store()).find((r) => r.key === "$hp")?.value).toBe(
|
||||||
|
"15",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
initReactivity({
|
||||||
|
declarations: [
|
||||||
|
{ key: "$a", expression: "$b + 1" },
|
||||||
|
{ key: "$b", expression: "$a + 1" },
|
||||||
|
],
|
||||||
|
tagModifiers: [],
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
|
||||||
|
// The failed re-init did not clobber the working configuration.
|
||||||
|
const results = computeInitialValues(store());
|
||||||
|
expect(results.find((r) => r.key === "$hp")?.value).toBe("15");
|
||||||
|
expect(results.find((r) => r.key === "$a")).toBeUndefined();
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("computeInitialValues", () => {
|
describe("computeInitialValues", () => {
|
||||||
@@ -539,6 +601,33 @@ describe("var-reactivity", () => {
|
|||||||
expect(hpResult?.value).toBe("60"); // 12 * 5
|
expect(hpResult?.value).toBe("60"); // 12 * 5
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reports the base alongside the combined value", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [{ key: "$hp", expression: "$con * 5" }],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = computeInitialValues({ $con: "12" });
|
||||||
|
const hp = results.find((r) => r.key === "$hp");
|
||||||
|
expect(hp?.value).toBe("60");
|
||||||
|
expect(hp?.base).toBe("60");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips variables already present in the store", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [
|
||||||
|
{ key: "$hp", expression: "10 + 5" },
|
||||||
|
{ key: "$ac", expression: "15" },
|
||||||
|
],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = computeInitialValues({ $hp: "99" }, new Set(["$hp"]));
|
||||||
|
// Existing value is left untouched; only missing keys are computed.
|
||||||
|
expect(results.find((r) => r.key === "$hp")).toBeUndefined();
|
||||||
|
expect(results.find((r) => r.key === "$ac")?.value).toBe("15");
|
||||||
|
});
|
||||||
|
|
||||||
test("returns empty array when no declarations", () => {
|
test("returns empty array when no declarations", () => {
|
||||||
initReactivity({ declarations: [], tagModifiers: [] });
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
const results = computeInitialValues(store());
|
const results = computeInitialValues(store());
|
||||||
@@ -546,6 +635,113 @@ describe("var-reactivity", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("diagnostics", () => {
|
||||||
|
test("reports a declaration that fails to evaluate", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
initReactivity({
|
||||||
|
declarations: [
|
||||||
|
{ key: "$hp", expression: "10 + 5" },
|
||||||
|
{ key: "$bad", expression: "#warrior * 2" },
|
||||||
|
],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = computeInitialValues(store());
|
||||||
|
expect(results.find((r) => r.key === "$hp")?.value).toBe("15");
|
||||||
|
expect(results.find((r) => r.key === "$bad")).toBeUndefined();
|
||||||
|
|
||||||
|
const diags = getReactivityDiagnostics();
|
||||||
|
expect(diags).toHaveLength(1);
|
||||||
|
expect(diags[0].kind).toBe("declaration");
|
||||||
|
expect(diags[0].key).toBe("$bad");
|
||||||
|
expect(diags[0].expression).toBe("#warrior * 2");
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dedupes repeated failures of the same expression", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
initReactivity({
|
||||||
|
declarations: [{ key: "$bad", expression: "#warrior * 2" }],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
computeInitialValues(store());
|
||||||
|
computeInitialValues(store());
|
||||||
|
expect(getReactivityDiagnostics()).toHaveLength(1);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears diagnostics on re-init", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
initReactivity({
|
||||||
|
declarations: [{ key: "$bad", expression: "#warrior * 2" }],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
computeInitialValues(store());
|
||||||
|
expect(getReactivityDiagnostics()).toHaveLength(1);
|
||||||
|
|
||||||
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
|
expect(getReactivityDiagnostics()).toHaveLength(0);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records a diagnostic when declarations are circular", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
expect(() =>
|
||||||
|
initReactivity({
|
||||||
|
declarations: [
|
||||||
|
{ key: "$a", expression: "$b + 1" },
|
||||||
|
{ key: "$b", expression: "$a + 1" },
|
||||||
|
],
|
||||||
|
tagModifiers: [],
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
|
||||||
|
const diags = getReactivityDiagnostics();
|
||||||
|
expect(diags).toHaveLength(1);
|
||||||
|
expect(diags[0].kind).toBe("init");
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports a tag modifier that fails to evaluate", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
initReactivity({
|
||||||
|
declarations: [],
|
||||||
|
tagModifiers: [
|
||||||
|
{ tag: "#warrior", target: "$hp", expression: "#x * 2", threshold: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
setBase("$class", "#warrior:1");
|
||||||
|
computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
|
||||||
|
|
||||||
|
const diags = getReactivityDiagnostics();
|
||||||
|
expect(diags).toHaveLength(1);
|
||||||
|
expect(diags[0].kind).toBe("modifier");
|
||||||
|
expect(diags[0].key).toBe("$hp");
|
||||||
|
expect(diags[0].tag).toBe("#warrior");
|
||||||
|
expect(diags[0].source).toBe("$class");
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps cascading other dependents when a declaration fails", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
initReactivity({
|
||||||
|
declarations: [
|
||||||
|
{ key: "$bad", expression: "$con * #warrior" },
|
||||||
|
{ key: "$good", expression: "$con + 1" },
|
||||||
|
],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cascade = computeCascade("$con", undefined, store({ $con: "5" }));
|
||||||
|
expect(cascade.find((r) => r.key === "$good")?.value).toBe("6");
|
||||||
|
expect(cascade.find((r) => r.key === "$bad")).toBeUndefined();
|
||||||
|
expect(getReactivityDiagnostics().some((d) => d.key === "$bad")).toBe(true);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getCombined / setBase", () => {
|
describe("getCombined / setBase", () => {
|
||||||
test("returns base value when no mods", () => {
|
test("returns base value when no mods", () => {
|
||||||
initReactivity({ declarations: [], tagModifiers: [] });
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
@@ -570,6 +766,127 @@ describe("var-reactivity", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveVariable", () => {
|
||||||
|
test("returns undefined for unknown variables", () => {
|
||||||
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
|
expect(resolveVariable("$nope")).toBeUndefined();
|
||||||
|
expect(resolveVariable("$nope", {})).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a real 0 rather than undefined", () => {
|
||||||
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
|
setBase("$zero", "0");
|
||||||
|
expect(resolveVariable("$zero")).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves keys present only in the fallback store", () => {
|
||||||
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
|
expect(resolveVariable("$hp", { $hp: "12" })).toBe("12");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rebuildReactivityFromStore", () => {
|
||||||
|
test("restores bases exactly without double-counting modifiers", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [],
|
||||||
|
tagModifiers: [
|
||||||
|
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// `variables` holds combined values; `bases` holds what derived them.
|
||||||
|
const hydrated = { $class: "#warrior:1", $mod_hp: "20" };
|
||||||
|
const bases = { $class: "#warrior:1", $mod_hp: "0" };
|
||||||
|
rebuildReactivityFromStore(hydrated, bases);
|
||||||
|
|
||||||
|
expect(getCombined("$mod_hp", hydrated)).toBe("20");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recovers a non-zero base and keeps modifiers applied", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [],
|
||||||
|
tagModifiers: [
|
||||||
|
{ tag: "#warrior", target: "$hp", expression: "20", threshold: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const hydrated = { $class: "#warrior:1", $hp: "70" }; // base 50 + 20
|
||||||
|
const bases = { $class: "#warrior:1", $hp: "50" };
|
||||||
|
rebuildReactivityFromStore(hydrated, bases);
|
||||||
|
|
||||||
|
expect(getCombined("$hp", hydrated)).toBe("70");
|
||||||
|
|
||||||
|
// A later /set replaces the base; the modifier still applies.
|
||||||
|
setBase("$hp", "60");
|
||||||
|
expect(getCombined("$hp", hydrated)).toBe("80");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deactivates a hydrated modifier on a later tag change", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [],
|
||||||
|
tagModifiers: [
|
||||||
|
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const hydrated = { $class: "#warrior:1", $mod_hp: "20" };
|
||||||
|
const bases = { $class: "#warrior:1", $mod_hp: "0" };
|
||||||
|
rebuildReactivityFromStore(hydrated, bases);
|
||||||
|
|
||||||
|
setBase("$class", "#mage:1");
|
||||||
|
const cascade = computeCascade("$class", "#warrior:1", {
|
||||||
|
...hydrated,
|
||||||
|
$class: "#mage:1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cascade.find((r) => r.key === "$mod_hp")?.value).toBe("0");
|
||||||
|
expect(getCombined("$mod_hp", { $class: "#mage:1" })).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rebuilds a tagmap variable's base from its published base", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [],
|
||||||
|
tagModifiers: [
|
||||||
|
{ tag: "#warrior", target: "$pool", expression: "2", threshold: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// $pool is a tagmap variable that receives a +2 #warrior tagmap mod.
|
||||||
|
const hydrated = { $class: "#warrior:1", $pool: "#druid:3;#warrior:2" };
|
||||||
|
const bases = { $class: "#warrior:1", $pool: "#druid:3" };
|
||||||
|
rebuildReactivityFromStore(hydrated, bases);
|
||||||
|
|
||||||
|
expect(getCombined("$pool", hydrated)).toBe("#druid:3;#warrior:2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is idempotent when called twice", () => {
|
||||||
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
|
const hydrated = { $hp: "42" };
|
||||||
|
rebuildReactivityFromStore(hydrated, { $hp: "42" });
|
||||||
|
rebuildReactivityFromStore(hydrated, { $hp: "42" });
|
||||||
|
expect(getCombined("$hp", hydrated)).toBe("42");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeCascade — declarations", () => {
|
||||||
|
test("does not re-publish or re-evaluate declared dependencies that did not change", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [
|
||||||
|
{ key: "$con", expression: "12" },
|
||||||
|
{ key: "$hp", expression: "$con * 5" },
|
||||||
|
],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// $con was set to 14; only its dependent $hp should be re-evaluated.
|
||||||
|
setBase("$con", "14");
|
||||||
|
const cascade = computeCascade("$con", "12", store({ $con: "14" }));
|
||||||
|
|
||||||
|
expect(cascade.map((r) => r.key)).toEqual(["$hp"]);
|
||||||
|
expect(cascade[0].value).toBe("70");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("computeCascade — tag activation/deactivation", () => {
|
describe("computeCascade — tag activation/deactivation", () => {
|
||||||
test("activates tag modifiers when a tag value is set", () => {
|
test("activates tag modifiers when a tag value is set", () => {
|
||||||
initReactivity({
|
initReactivity({
|
||||||
@@ -605,6 +922,27 @@ describe("var-reactivity", () => {
|
|||||||
expect(modStr?.value).toBe("5");
|
expect(modStr?.value).toBe("5");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reports the raw base alongside the combined value", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [],
|
||||||
|
tagModifiers: [
|
||||||
|
{ tag: "#warrior", target: "$hp", expression: "20", threshold: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
setBase("$hp", "50");
|
||||||
|
setBase("$class", "#warrior:1");
|
||||||
|
const cascade = computeCascade(
|
||||||
|
"$class",
|
||||||
|
undefined,
|
||||||
|
store({ $class: "#warrior:1" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hp = cascade.find((r) => r.key === "$hp");
|
||||||
|
expect(hp?.value).toBe("70");
|
||||||
|
expect(hp?.base).toBe("50");
|
||||||
|
});
|
||||||
|
|
||||||
test("deactivates tag modifiers when tag is removed from tagmap", () => {
|
test("deactivates tag modifiers when tag is removed from tagmap", () => {
|
||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
|
|||||||
@@ -29,6 +29,35 @@ describe("scanDoc", () => {
|
|||||||
expect(entry.role).toBe("spark-table");
|
expect(entry.role).toBe("spark-table");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("tilde-fenced role blocks are scanned", () => {
|
||||||
|
const md = [
|
||||||
|
"~~~csv role=spark-table",
|
||||||
|
"d6,Name",
|
||||||
|
"1,Alice",
|
||||||
|
"~~~",
|
||||||
|
].join("\n");
|
||||||
|
const { stripped, content } = scanDoc(md, "test.md");
|
||||||
|
|
||||||
|
expect(stripped).toMatch(/^:md-table\[\.\/csv_/);
|
||||||
|
const [entry] = Object.values(content);
|
||||||
|
expect(entry.body).toContain("d6,Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tilde-fenced declare blocks are scanned", () => {
|
||||||
|
const md = [
|
||||||
|
"~~~yaml role=declare",
|
||||||
|
"vars:",
|
||||||
|
" $hp: 10",
|
||||||
|
"~~~",
|
||||||
|
].join("\n");
|
||||||
|
const { stripped, content } = scanDoc(md, "test.md");
|
||||||
|
|
||||||
|
expect(stripped).toBe("");
|
||||||
|
const [entry] = Object.values(content);
|
||||||
|
expect(entry.kind).toBe("declare");
|
||||||
|
expect(entry.body).toContain("$hp: 10");
|
||||||
|
});
|
||||||
|
|
||||||
test("markdown role=spark-table body is converted to CSV", () => {
|
test("markdown role=spark-table body is converted to CSV", () => {
|
||||||
const md = [
|
const md = [
|
||||||
"```markdown role=spark-table",
|
"```markdown role=spark-table",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
import Slugger from "github-slugger";
|
import Slugger from "github-slugger";
|
||||||
import {
|
import {
|
||||||
parseDeclareCsv,
|
parseDeclare,
|
||||||
type VarDeclaration,
|
type VarDeclaration,
|
||||||
type TagModifier,
|
type TagModifier,
|
||||||
} from "./completions/declare-parser.js";
|
} from "./completions/declare-parser.js";
|
||||||
@@ -124,6 +124,7 @@ export function scanDoc(content: string, docPath: string): DocScanResult {
|
|||||||
FENCED_BLOCK_RE,
|
FENCED_BLOCK_RE,
|
||||||
(
|
(
|
||||||
match: string,
|
match: string,
|
||||||
|
_marker: string,
|
||||||
lang: string,
|
lang: string,
|
||||||
infoString: string,
|
infoString: string,
|
||||||
body: string,
|
body: string,
|
||||||
@@ -471,7 +472,7 @@ export function deriveBlocks(registry: ContentRegistry): {
|
|||||||
for (const entry of Object.values(store)) {
|
for (const entry of Object.values(store)) {
|
||||||
if (entry.kind !== "declare") continue;
|
if (entry.kind !== "declare") continue;
|
||||||
try {
|
try {
|
||||||
const result = parseDeclareCsv(entry.body, docPath);
|
const result = parseDeclare(entry.body);
|
||||||
declarations.push(...result.variables);
|
declarations.push(...result.variables);
|
||||||
tagModifiers.push(...result.tagModifiers);
|
tagModifiers.push(...result.tagModifiers);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
* Code-block role checks.
|
* Code-block role checks.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parse } from "csv-parse/browser/esm/sync";
|
import { parseDeclare, type DeclareResult } from "../../completions/declare-parser.js";
|
||||||
import { parseDeclareCsv } from "../../completions/declare-parser.js";
|
|
||||||
import { diagnosticAt } from "../diagnostics.js";
|
import { diagnosticAt } from "../diagnostics.js";
|
||||||
import { ELEMENT_NAMES, KNOWN_ROLES } from "../known.js";
|
import { ELEMENT_NAMES, KNOWN_ROLES } from "../known.js";
|
||||||
import type { RawFence } from "../scan.js";
|
import type { RawFence } from "../scan.js";
|
||||||
@@ -60,8 +59,25 @@ function checkSparkTable(fence: RawFence, ctx: CheckContext): void {
|
|||||||
|
|
||||||
function checkDeclare(fence: RawFence, ctx: CheckContext): void {
|
function checkDeclare(fence: RawFence, ctx: CheckContext): void {
|
||||||
const { source, file, sink } = ctx;
|
const { source, file, sink } = ctx;
|
||||||
|
|
||||||
|
if (/^csv$/i.test(fence.lang)) {
|
||||||
|
sink.report(
|
||||||
|
diagnosticAt(
|
||||||
|
file,
|
||||||
|
source,
|
||||||
|
fence.offset,
|
||||||
|
"TTTK-R003",
|
||||||
|
"error",
|
||||||
|
"role=declare blocks are YAML, not CSV",
|
||||||
|
"use ```yaml role=declare with `vars:` and `tags:` maps",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: DeclareResult;
|
||||||
try {
|
try {
|
||||||
parseDeclareCsv(fence.body, file);
|
result = parseDeclare(fence.body);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sink.report(
|
sink.report(
|
||||||
diagnosticAt(
|
diagnosticAt(
|
||||||
@@ -71,51 +87,22 @@ function checkDeclare(fence: RawFence, ctx: CheckContext): void {
|
|||||||
"TTTK-R003",
|
"TTTK-R003",
|
||||||
"error",
|
"error",
|
||||||
error instanceof Error ? error.message : String(error),
|
error instanceof Error ? error.message : String(error),
|
||||||
"role=declare needs `key` and `expr` columns; `tag` and `threshold` are optional",
|
"role=declare is YAML: `vars:` maps `$name` to an expression; `tags:` maps a tag name to threshold maps",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
checkDeclareRows(fence, ctx);
|
if (result.variables.length === 0 && result.tagModifiers.length === 0) {
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* R004 — `parseDeclareCsv` silently skips rows with an empty `key` or `expr`.
|
|
||||||
* Re-parse with line info so the skipped row can be localized.
|
|
||||||
*/
|
|
||||||
function checkDeclareRows(fence: RawFence, ctx: CheckContext): void {
|
|
||||||
const { source, file, sink } = ctx;
|
|
||||||
let records: Array<{ record: Record<string, string>; info: { lines: number } }>;
|
|
||||||
try {
|
|
||||||
records = parse(fence.body, {
|
|
||||||
columns: true,
|
|
||||||
trim: true,
|
|
||||||
skipEmptyLines: true,
|
|
||||||
info: true,
|
|
||||||
}) as unknown as typeof records;
|
|
||||||
} catch {
|
|
||||||
return; // R003 already reported the parse failure
|
|
||||||
}
|
|
||||||
if (records.length === 0) return;
|
|
||||||
|
|
||||||
const headers = Object.keys(records[0].record).map((h) => h.toLowerCase());
|
|
||||||
if (!headers.includes("key") || !headers.includes("expr")) return;
|
|
||||||
|
|
||||||
const baseLine = source.position(fence.bodyOffset).line;
|
|
||||||
for (const { record, info } of records) {
|
|
||||||
const key = record.key ?? "";
|
|
||||||
const expr = record.expr ?? "";
|
|
||||||
if (key && expr) continue;
|
|
||||||
sink.report(
|
sink.report(
|
||||||
diagnosticAt(
|
diagnosticAt(
|
||||||
file,
|
file,
|
||||||
source,
|
source,
|
||||||
source.lineStart(baseLine + info.lines - 1),
|
fence.offset,
|
||||||
"TTTK-R004",
|
"TTTK-R004",
|
||||||
"warning",
|
"warning",
|
||||||
`declare row skipped: ${!key ? "empty key" : "empty expr"}`,
|
"role=declare block declares nothing",
|
||||||
"every declare row needs both a key and an expression",
|
"add a `vars:` or `tags:` mapping; the block has no effect",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,16 +131,6 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
|||||||
':md-pins[./images/dungeon.png]{labelStart="1"}',
|
':md-pins[./images/dungeon.png]{labelStart="1"}',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "md-commander",
|
|
||||||
body: "command template CSV",
|
|
||||||
attrs: ["placeholder", "class", "height"],
|
|
||||||
summary: "Interactive command history / tracker panel.",
|
|
||||||
details:
|
|
||||||
"The body is a CSV of command templates (`command,parameter,label,description,insertedText`).",
|
|
||||||
refKind: "csv",
|
|
||||||
examples: [':md-commander[./commands.csv]{placeholder="输入命令…" height="400px"}'],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "md-yarn-spinner",
|
name: "md-yarn-spinner",
|
||||||
body: "Yarn Spinner file",
|
body: "Yarn Spinner file",
|
||||||
@@ -232,12 +222,12 @@ export const ROLE_SPECS: readonly RoleSpec[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "declare",
|
role: "declare",
|
||||||
info: "csv role=declare",
|
info: "yaml role=declare",
|
||||||
summary: "Reactive variable / tag-modifier definitions.",
|
summary: "Reactive variable / tag-modifier definitions.",
|
||||||
details:
|
details:
|
||||||
"Columns (any order): `tag`, `threshold`, `key`, `expr`. A row with an empty `tag` declares a variable; a row with a `#tag` applies a modifier when that tag is active.",
|
"YAML with two optional maps. `vars:` maps a `$name` to its expression. `tags:` maps a tag name to a map of thresholds to `$target` modifiers: at that tag count the target gains the value, so sibling thresholds stack. Tag names may be written with or without the leading `#`. YAML resolves bare numbers to numbers and everything else to strings; both become expression text.",
|
||||||
examples: [
|
examples: [
|
||||||
"```csv role=declare\ntag,threshold,key,expr\n,,$hp,$con*5+$mod_hp\n#warrior,2,$mod_hp,20\n```",
|
"```yaml role=declare\nvars:\n $hp: $con*5 + $mod_hp\n $ac: 10 + $dex\ntags:\n warrior:\n 1:\n $mod_hp: 20\n 2:\n $mod_str: 1\n```",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -282,6 +272,7 @@ export interface DiceNotation {
|
|||||||
export const DICE_NOTATION: readonly DiceNotation[] = [
|
export const DICE_NOTATION: readonly DiceNotation[] = [
|
||||||
{ notation: "2d6", meaning: "two six-sided dice" },
|
{ notation: "2d6", meaning: "two six-sided dice" },
|
||||||
{ notation: "1d20+5", meaning: "d20 plus modifier" },
|
{ notation: "1d20+5", meaning: "d20 plus modifier" },
|
||||||
|
{ notation: "d20", meaning: "one twenty-sided die (count optional)" },
|
||||||
{ notation: "3d6kh1", meaning: "keep highest 1" },
|
{ notation: "3d6kh1", meaning: "keep highest 1" },
|
||||||
{ notation: "3d6dl2-1", meaning: "drop lowest 2, minus 1" },
|
{ notation: "3d6dl2-1", meaning: "drop lowest 2, minus 1" },
|
||||||
{ notation: "{d4, d8}", meaning: "choose one of the listed dice" },
|
{ notation: "{d4, d8}", meaning: "choose one of the listed dice" },
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ export function renderReference(): string {
|
|||||||
);
|
);
|
||||||
lines.push("handled.");
|
lines.push("handled.");
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
lines.push(
|
||||||
|
"Fences may use backticks or tildes (`~~~`); the closing fence must reuse",
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
"the opening marker. Fence attribute keys may contain hyphens (e.g.",
|
||||||
|
);
|
||||||
|
lines.push("`data-foo=bar`).");
|
||||||
|
lines.push("");
|
||||||
for (const role of ROLE_SPECS) {
|
for (const role of ROLE_SPECS) {
|
||||||
lines.push(`### \`role=${role.role}\``);
|
lines.push(`### \`role=${role.role}\``);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|||||||
@@ -65,14 +65,26 @@ describe("runCheck", () => {
|
|||||||
expect(report.diagnostics[0].line).toBe(1);
|
expect(report.diagnostics[0].line).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("R003 flags a declare block missing key/expr columns", () => {
|
test("R003 flags a declare block with an invalid shape", () => {
|
||||||
const dir = fixture({
|
const dir = fixture({
|
||||||
"doc.md": "```csv role=declare\nfoo,bar\n1,2\n```\n",
|
"doc.md": "```yaml role=declare\nvars:\n hp: 1\n```\n",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(codes(runCheck(dir))).toContain("TTTK-R003");
|
expect(codes(runCheck(dir))).toContain("TTTK-R003");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("R003 flags a CSV role=declare block with a migration hint", () => {
|
||||||
|
const dir = fixture({
|
||||||
|
"doc.md": "```csv role=declare\ntag,key,expr\n,,$hp,1\n```\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = runCheck(dir);
|
||||||
|
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R003");
|
||||||
|
|
||||||
|
expect(diagnostic).toBeDefined();
|
||||||
|
expect(diagnostic!.message).toContain("YAML, not CSV");
|
||||||
|
});
|
||||||
|
|
||||||
test("R005 flags invalid YAML in a role=tag block", () => {
|
test("R005 flags invalid YAML in a role=tag block", () => {
|
||||||
const dir = fixture({
|
const dir = fixture({
|
||||||
"doc.md": "```yaml role=tag\ntag: [unclosed\n```\n",
|
"doc.md": "```yaml role=tag\ntag: [unclosed\n```\n",
|
||||||
@@ -168,16 +180,16 @@ describe("runCheck", () => {
|
|||||||
expect(codes(runCheck(dir))).not.toContain("TTTK-D002");
|
expect(codes(runCheck(dir))).not.toContain("TTTK-D002");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("R004 flags a declare row with an empty key or expr", () => {
|
test("R004 flags a declare block that declares nothing", () => {
|
||||||
const dir = fixture({
|
const dir = fixture({
|
||||||
"doc.md": "```csv role=declare\nkey,expr\n$hp,1\n,2\n```\n",
|
"doc.md": "```yaml role=declare\n# nothing here\n```\n",
|
||||||
});
|
});
|
||||||
|
|
||||||
const report = runCheck(dir);
|
const report = runCheck(dir);
|
||||||
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R004");
|
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R004");
|
||||||
|
|
||||||
expect(diagnostic).toBeDefined();
|
expect(diagnostic).toBeDefined();
|
||||||
expect(diagnostic!.line).toBe(4);
|
expect(diagnostic!.line).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("V001 flags a CSV that fails to parse", () => {
|
test("V001 flags a CSV that fails to parse", () => {
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ export const CommandLinkManager: Component = () => {
|
|||||||
command,
|
command,
|
||||||
sparkTables: comp.data.sparkTables,
|
sparkTables: comp.data.sparkTables,
|
||||||
variables: stream.variables,
|
variables: stream.variables,
|
||||||
declarations: comp.data.declarations,
|
|
||||||
tagModifiers: comp.data.tagModifiers,
|
|
||||||
}).then((result) => {
|
}).then((result) => {
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setDispatchError(result.error);
|
setDispatchError(result.error);
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { tokenize, parse, roll, applyModifier, rollFormula } from "./index";
|
||||||
|
import type { DieResult } from "./index";
|
||||||
|
|
||||||
|
describe("dice engine — tokenizer", () => {
|
||||||
|
test("tokenizes NdX", () => {
|
||||||
|
expect(tokenize("3d6")).toEqual([{ type: "dice", count: 3, sides: 6 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tokenizes bare dX as a single die", () => {
|
||||||
|
expect(tokenize("d20")).toEqual([{ type: "dice", count: 1, sides: 20 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tokenizes a pool literal", () => {
|
||||||
|
expect(tokenize("{d4,d8}")).toEqual([
|
||||||
|
{ type: "pool_start" },
|
||||||
|
{ type: "dice", count: 1, sides: 4 },
|
||||||
|
{ type: "comma" },
|
||||||
|
{ type: "dice", count: 1, sides: 8 },
|
||||||
|
{ type: "pool_end" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tokenizes a keep-highest modifier", () => {
|
||||||
|
expect(tokenize("3d6kh2")).toEqual([
|
||||||
|
{ type: "dice", count: 3, sides: 6 },
|
||||||
|
{ type: "modifier", modType: "kh", count: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores whitespace", () => {
|
||||||
|
expect(tokenize(" 3d6 + 5 ")).toEqual([
|
||||||
|
{ type: "dice", count: 3, sides: 6 },
|
||||||
|
{ type: "plus" },
|
||||||
|
{ type: "number", value: 5 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dice engine — parser", () => {
|
||||||
|
test("builds one pool for NdX", () => {
|
||||||
|
const pools = parse(tokenize("3d6"));
|
||||||
|
expect(pools).toHaveLength(1);
|
||||||
|
expect(pools[0].dice).toHaveLength(3);
|
||||||
|
expect(pools[0].dice.every((d) => d.sides === 6)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds a combined pool list for +", () => {
|
||||||
|
const pools = parse(tokenize("3d6+5"));
|
||||||
|
expect(pools).toHaveLength(2);
|
||||||
|
expect(pools[1].dice[0]).toMatchObject({ sides: 0, value: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marks a subtracted pool negative", () => {
|
||||||
|
const pools = parse(tokenize("3d6-2"));
|
||||||
|
expect(pools).toHaveLength(2);
|
||||||
|
expect(pools[1].isNegative).toBe(true);
|
||||||
|
expect(pools[1].dice[0].isNegative).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dice engine — applyModifier", () => {
|
||||||
|
const dice = (...values: number[]): DieResult[] =>
|
||||||
|
values.map((value) => ({ sides: 6, value, isNegative: false }));
|
||||||
|
|
||||||
|
test("keeps the highest N", () => {
|
||||||
|
const { keptRolls, droppedRolls } = applyModifier(dice(2, 6, 4), {
|
||||||
|
type: "kh",
|
||||||
|
count: 2,
|
||||||
|
});
|
||||||
|
expect(keptRolls.map((r) => r.value).sort()).toEqual([4, 6]);
|
||||||
|
expect(droppedRolls.map((r) => r.value)).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops the lowest N", () => {
|
||||||
|
const { keptRolls, droppedRolls } = applyModifier(dice(2, 6, 4, 1), {
|
||||||
|
type: "dl",
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
expect(keptRolls.map((r) => r.value).sort()).toEqual([2, 4, 6]);
|
||||||
|
expect(droppedRolls.map((r) => r.value)).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps all when there is no modifier", () => {
|
||||||
|
const { keptRolls, droppedRolls } = applyModifier(dice(1, 2), undefined);
|
||||||
|
expect(keptRolls).toHaveLength(2);
|
||||||
|
expect(droppedRolls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dice engine — roll", () => {
|
||||||
|
test("rolls 3d6 within range", () => {
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
const result = roll("3d6");
|
||||||
|
expect(result.total).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(result.total).toBeLessThanOrEqual(18);
|
||||||
|
expect(result.pools).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rolls d20 within range", () => {
|
||||||
|
const result = roll("d20");
|
||||||
|
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(result.total).toBeLessThanOrEqual(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds fixed numbers to the total", () => {
|
||||||
|
const result = roll("3d6+5");
|
||||||
|
expect(result.total).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(result.total).toBeLessThanOrEqual(23);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keep-highest keeps N dice and totals only those", () => {
|
||||||
|
const result = roll("3d6kh2");
|
||||||
|
const [pool] = result.pools;
|
||||||
|
expect(pool.keptRolls).toHaveLength(2);
|
||||||
|
expect(pool.droppedRolls).toHaveLength(1);
|
||||||
|
expect(result.total).toBe(pool.subtotal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits matching HTML and plain-text detail", () => {
|
||||||
|
const result = roll("2d6");
|
||||||
|
expect(result.detail).toContain(String(result.total));
|
||||||
|
expect(result.detail).toContain("<strong>");
|
||||||
|
expect(result.plainDetail).toContain(String(result.total));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dice engine — rollFormula", () => {
|
||||||
|
test("normalizes a successful roll", () => {
|
||||||
|
const result = rollFormula("3d6");
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.formula).toBe("3d6");
|
||||||
|
expect(result.result.total).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(result.result.pools[0].rolls).toHaveLength(3);
|
||||||
|
expect(result.result.pools[0].keptRolls).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports pool literals and modifiers", () => {
|
||||||
|
const result = rollFormula("{d4,d8}kh2-5");
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.result.pools.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Dice engine — shared infrastructure for every dice-rolling feature
|
||||||
|
* (`:md-dice`, spark tables, journal `/roll`, and dice inside variable
|
||||||
|
* expressions).
|
||||||
|
*
|
||||||
|
* `rollFormula` is the normalized entry point; the tokenizer/parser/roller are
|
||||||
|
* exported for tests and for callers that need the structured pool results.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { rollFormula } from "./rollFormula";
|
||||||
|
export type { DiceRollerResult } from "./rollFormula";
|
||||||
|
|
||||||
|
export { tokenize, parse } from "./parser";
|
||||||
|
export { roll, rollDie, rollDicePool, applyModifier } from "./roller";
|
||||||
|
export type {
|
||||||
|
DieResult,
|
||||||
|
DicePool,
|
||||||
|
RollResult,
|
||||||
|
DicePoolResult,
|
||||||
|
Token,
|
||||||
|
} from "./types";
|
||||||
+8
-33
@@ -1,4 +1,4 @@
|
|||||||
import { roll as rollDice } from "./dice-engine";
|
import { roll as rollDice } from "./roller";
|
||||||
|
|
||||||
export interface DiceRollerResult {
|
export interface DiceRollerResult {
|
||||||
formula: string;
|
formula: string;
|
||||||
@@ -16,19 +16,14 @@ export interface DiceRollerResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 骰子引擎 Hook
|
|
||||||
* 提供高级骰子功能:骰池、修饰符、组合等
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* 执行掷骰
|
* 执行掷骰
|
||||||
* @param formula 骰子公式,支持:
|
*
|
||||||
* - 3d6: 标准骰子
|
* Supports pools, modifiers and combinations. For example:
|
||||||
* - {d4, d8, 2d6}: 骰池字面量
|
* - `3d6`: standard dice
|
||||||
* - kh2/dl1: 修饰符
|
* - `{d4, d8, 2d6}`: pool literal
|
||||||
* - +: 组合骰池
|
* - `kh2` / `dl1`: keep-high / drop-low modifiers
|
||||||
* - -: 负数组合
|
* - `+` / `-`: combine pools; a bare number is a fixed value
|
||||||
* - 5: 固定数字
|
|
||||||
*/
|
*/
|
||||||
export function rollFormula(formula: string): DiceRollerResult {
|
export function rollFormula(formula: string): DiceRollerResult {
|
||||||
try {
|
try {
|
||||||
@@ -63,24 +58,4 @@ export function rollFormula(formula: string): DiceRollerResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 简化版掷骰,返回 HTML 格式结果
|
|
||||||
* 格式:[1] [2] [3] = <strong>6</strong>
|
|
||||||
*/
|
|
||||||
export function rollSimple(formula: string): {
|
|
||||||
text: string;
|
|
||||||
isHtml?: boolean;
|
|
||||||
} {
|
|
||||||
try {
|
|
||||||
const result = rollDice(formula);
|
|
||||||
return {
|
|
||||||
text: result.detail,
|
|
||||||
isHtml: true, // detail 包含 HTML
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
text: `错误:${e instanceof Error ? e.message : String(e)}`,
|
|
||||||
isHtml: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -50,8 +50,6 @@ import mdEmbedRaw from "../doc-entries/md-embed.md";
|
|||||||
import mdDeckRaw from "../doc-entries/md-deck.md";
|
import mdDeckRaw from "../doc-entries/md-deck.md";
|
||||||
import mdYarnRaw from "../doc-entries/md-yarn-spinner.md";
|
import mdYarnRaw from "../doc-entries/md-yarn-spinner.md";
|
||||||
|
|
||||||
import mdCommanderRaw from "../doc-entries/md-commander.md";
|
|
||||||
|
|
||||||
const rawDocuments: string[] = [
|
const rawDocuments: string[] = [
|
||||||
mdDiceRaw,
|
mdDiceRaw,
|
||||||
mdTableRaw,
|
mdTableRaw,
|
||||||
@@ -63,8 +61,6 @@ const rawDocuments: string[] = [
|
|||||||
mdEmbedRaw,
|
mdEmbedRaw,
|
||||||
mdDeckRaw,
|
mdDeckRaw,
|
||||||
mdYarnRaw,
|
mdYarnRaw,
|
||||||
|
|
||||||
mdCommanderRaw,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let _entries: DocEntry[] | null = null;
|
let _entries: DocEntry[] | null = null;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import "./md-border";
|
|||||||
import "./md-font";
|
import "./md-font";
|
||||||
import "./md-embed";
|
import "./md-embed";
|
||||||
import "./md-deck";
|
import "./md-deck";
|
||||||
import "./md-commander/index";
|
|
||||||
import "./md-yarn-spinner";
|
import "./md-yarn-spinner";
|
||||||
|
|
||||||
// 导出组件
|
// 导出组件
|
||||||
@@ -33,29 +32,3 @@ export type { FontProps } from "./md-font";
|
|||||||
export type { BorderProps } from "./md-border";
|
export type { BorderProps } from "./md-border";
|
||||||
export type { EmbedProps } from "./md-embed";
|
export type { EmbedProps } from "./md-embed";
|
||||||
export type { YarnSpinnerProps } from "./md-yarn-spinner";
|
export type { YarnSpinnerProps } from "./md-yarn-spinner";
|
||||||
|
|
||||||
// 导出 md-commander 相关
|
|
||||||
export type {
|
|
||||||
MdCommanderProps,
|
|
||||||
MdCommanderCommand,
|
|
||||||
MdCommanderCommandMap,
|
|
||||||
MdCommanderParameter,
|
|
||||||
MdCommanderOption,
|
|
||||||
MdCommanderOptionType,
|
|
||||||
CommanderEntry,
|
|
||||||
CompletionItem,
|
|
||||||
TrackerItem,
|
|
||||||
TrackerAttribute,
|
|
||||||
TrackerAttributeType,
|
|
||||||
TrackerCommand,
|
|
||||||
TrackerViewMode,
|
|
||||||
} from "./md-commander/types";
|
|
||||||
export { TabBar } from "./md-commander/TabBar";
|
|
||||||
export { TrackerView } from "./md-commander/TrackerView";
|
|
||||||
export { CommanderEntries } from "./md-commander/CommanderEntries";
|
|
||||||
export { CommanderInput } from "./md-commander/CommanderInput";
|
|
||||||
export { useCommander } from "./md-commander/hooks";
|
|
||||||
export {
|
|
||||||
initializeCommands,
|
|
||||||
getCommands,
|
|
||||||
} from "./md-commander/stores/commandsStore";
|
|
||||||
|
|||||||
@@ -109,8 +109,6 @@ export const JournalInput: Component = () => {
|
|||||||
command: raw,
|
command: raw,
|
||||||
sparkTables: comp.data.sparkTables,
|
sparkTables: comp.data.sparkTables,
|
||||||
variables: stream.variables,
|
variables: stream.variables,
|
||||||
declarations: comp.data.declarations,
|
|
||||||
tagModifiers: comp.data.tagModifiers,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// dispatchCommand already set the shared error if it failed.
|
// dispatchCommand already set the shared error if it failed.
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export const VariableView: Component = () => {
|
|||||||
|
|
||||||
const values = createMemo(() => stream.variables);
|
const values = createMemo(() => stream.variables);
|
||||||
|
|
||||||
|
/** Runtime engine diagnostics (bad declarations / modifiers). */
|
||||||
|
const diagnostics = createMemo(() => comp.diagnostics());
|
||||||
|
|
||||||
/** Flat list of all variables */
|
/** Flat list of all variables */
|
||||||
const items = createMemo(() => {
|
const items = createMemo(() => {
|
||||||
const vars = values();
|
const vars = values();
|
||||||
@@ -32,11 +35,25 @@ export const VariableView: Component = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="h-full overflow-y-auto bg-gray-50">
|
<div class="h-full overflow-y-auto bg-gray-50">
|
||||||
|
<Show when={diagnostics().length > 0}>
|
||||||
|
<div class="border-b border-amber-200 bg-amber-50 px-3 py-2">
|
||||||
|
<div class="text-xs font-semibold text-amber-800 mb-1">
|
||||||
|
变量求值有 {diagnostics().length} 个问题
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<For each={diagnostics()}>
|
||||||
|
{(d) => (
|
||||||
|
<li class="text-xs text-amber-700 font-mono break-all">{d.message}</li>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={hasAnyData()}
|
when={hasAnyData()}
|
||||||
fallback={
|
fallback={
|
||||||
<p class="text-center text-gray-400 text-xs py-8">
|
<p class="text-center text-gray-400 text-xs py-8">
|
||||||
暂无变量定义。在文档中使用 ```csv role=declare 代码块定义变量。
|
暂无变量定义。在文档中使用 ```yaml role=declare 代码块定义变量。
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { parseInput } from "./command-parser";
|
|||||||
import { resolveRollPayload } from "./types/roll";
|
import { resolveRollPayload } from "./types/roll";
|
||||||
import { resolveSparkPayload } from "./types/spark";
|
import { resolveSparkPayload } from "./types/spark";
|
||||||
import { evaluateExpression, exprValueToString } from "./variable-expression";
|
import { evaluateExpression, exprValueToString } from "./variable-expression";
|
||||||
import { computeCascade, getCombined, setBase } from "./var-reactivity";
|
import { computeCascade, getCombined, resolveVariable, setBase } from "./var-reactivity";
|
||||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
import { ensureReactivity } from "./completions";
|
||||||
|
|
||||||
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
|
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
|
||||||
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
@@ -58,10 +58,6 @@ export interface DispatchContext {
|
|||||||
}[];
|
}[];
|
||||||
/** Current runtime variable values */
|
/** Current runtime variable values */
|
||||||
variables: Record<string, string>;
|
variables: Record<string, string>;
|
||||||
/** Variable declarations (from role=declare blocks) */
|
|
||||||
declarations: VarDeclaration[];
|
|
||||||
/** Tag modifiers (from role=declare blocks) */
|
|
||||||
tagModifiers: TagModifier[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,6 +70,10 @@ export async function dispatchCommand(
|
|||||||
const raw = ctx.command.trim();
|
const raw = ctx.command.trim();
|
||||||
if (!raw) return { ok: false, error: "Empty command" };
|
if (!raw) return { ok: false, error: "Empty command" };
|
||||||
|
|
||||||
|
// Make sure the engine reflects the currently loaded declarations; this
|
||||||
|
// re-initializes only when they changed since the last dispatch.
|
||||||
|
ensureReactivity();
|
||||||
|
|
||||||
// Observers can only send chat
|
// Observers can only send chat
|
||||||
if (ctx.role === "observer") {
|
if (ctx.role === "observer") {
|
||||||
const result = sendMessage("chat", { text: raw });
|
const result = sendMessage("chat", { text: raw });
|
||||||
@@ -131,7 +131,8 @@ export async function dispatchCommand(
|
|||||||
if (arg.includes("$")) {
|
if (arg.includes("$")) {
|
||||||
try {
|
try {
|
||||||
const ev = evaluateExpression(arg, {
|
const ev = evaluateExpression(arg, {
|
||||||
lookup: (name: string) => getCombined("$" + name, ctx.variables),
|
// Strict: referencing an undefined variable is an error, not 0.
|
||||||
|
lookup: (name) => resolveVariable(name, ctx.variables),
|
||||||
});
|
});
|
||||||
if (ev.value.kind !== "number") {
|
if (ev.value.kind !== "number") {
|
||||||
return finish({ ok: false, error: "Roll expression must evaluate to a number" });
|
return finish({ ok: false, error: "Roll expression must evaluate to a number" });
|
||||||
@@ -204,11 +205,8 @@ function dispatchSet(
|
|||||||
} else if (p.expr) {
|
} else if (p.expr) {
|
||||||
// Evaluate expression — handles both numeric and tagmap values
|
// Evaluate expression — handles both numeric and tagmap values
|
||||||
const result = evaluateExpression(p.expr, {
|
const result = evaluateExpression(p.expr, {
|
||||||
lookup: (name: string) => {
|
// Strict: referencing an undefined variable is an error, not 0.
|
||||||
const k = "$" + name;
|
lookup: (name) => resolveVariable(name, ctx.variables),
|
||||||
// Use getCombined with stream fallback for accurate values
|
|
||||||
return k === key ? undefined : getCombined(k, ctx.variables);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
newValue = exprValueToString(result.value);
|
newValue = exprValueToString(result.value);
|
||||||
} else {
|
} else {
|
||||||
@@ -236,6 +234,7 @@ function dispatchSet(
|
|||||||
action: "set",
|
action: "set",
|
||||||
key: change.key,
|
key: change.key,
|
||||||
value: change.value,
|
value: change.value,
|
||||||
|
base: change.base,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -244,10 +243,15 @@ function dispatchSet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send the direct set last so cascade messages arrive first.
|
// Send the direct set last so cascade messages arrive first.
|
||||||
// Use getCombined with stream fallback so the stream store receives the
|
// Publish the combined value for display plus the raw base so hydrated
|
||||||
// correct value (base + active mods) rather than just the raw base.
|
// clients can rebuild the engine exactly (see rebuildReactivityFromStore).
|
||||||
const combinedValue = getCombined(key, ctx.variables);
|
const combinedValue = getCombined(key, ctx.variables);
|
||||||
const r1 = sendMessage("var", { action: "set", key, value: combinedValue });
|
const r1 = sendMessage("var", {
|
||||||
|
action: "set",
|
||||||
|
key,
|
||||||
|
value: combinedValue,
|
||||||
|
base: newValue,
|
||||||
|
});
|
||||||
const u1 = unwrap(r1);
|
const u1 = unwrap(r1);
|
||||||
if (!u1.ok) return u1;
|
if (!u1.ok) return u1;
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,13 @@ import type {
|
|||||||
VarDeclaration,
|
VarDeclaration,
|
||||||
TagModifier,
|
TagModifier,
|
||||||
} from "../../cli/completions/types";
|
} from "../../cli/completions/types";
|
||||||
import { initReactivity, computeInitialValues } from "./var-reactivity";
|
import { initReactivity, computeInitialValues, rebuildReactivityFromStore, getReactivityDiagnostics, onReactivityDiagnostics, type ReactivityDiagnostic } from "./var-reactivity";
|
||||||
import { sendMessage, journalStreamState } from "../stores/journalStream";
|
import {
|
||||||
|
sendMessage,
|
||||||
|
journalStreamState,
|
||||||
|
hasHydrated,
|
||||||
|
setHydrationListener,
|
||||||
|
} from "../stores/journalStream";
|
||||||
|
|
||||||
export type { VarDeclaration, TagModifier };
|
export type { VarDeclaration, TagModifier };
|
||||||
|
|
||||||
@@ -53,6 +58,14 @@ const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
|
|||||||
status: "loading",
|
status: "loading",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Runtime diagnostics from the variable engine (bad declarations, modifier
|
||||||
|
// expressions, circular deps). Bridged into a signal so components can render
|
||||||
|
// them; the engine itself stays Solid-free.
|
||||||
|
const [reactivityDiagnostics, setReactivityDiagnostics] = createSignal<
|
||||||
|
ReactivityDiagnostic[]
|
||||||
|
>(getReactivityDiagnostics());
|
||||||
|
onReactivityDiagnostics((list) => setReactivityDiagnostics(list));
|
||||||
|
|
||||||
// The registry backing the completions. Populated in both CLI and client
|
// The registry backing the completions. Populated in both CLI and client
|
||||||
// modes so inline content ids can be resolved at runtime (e.g. spark rolls).
|
// modes so inline content ids can be resolved at runtime (e.g. spark rolls).
|
||||||
let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||||
@@ -133,22 +146,84 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
|||||||
|
|
||||||
// ------------------- Init (runs eagerly at import time) -------------------
|
// ------------------- Init (runs eagerly at import time) -------------------
|
||||||
|
|
||||||
|
// Declarations finish loading asynchronously, as does worker hydration. Either
|
||||||
|
// may land first, so seeding is driven by whichever becomes ready last.
|
||||||
|
let _reactivityReady = false;
|
||||||
|
|
||||||
|
/** Signature of the declarations/tag modifiers last applied to the engine. */
|
||||||
|
let _reactivitySignature: string | null = null;
|
||||||
|
|
||||||
|
function reactivitySignature(
|
||||||
|
declarations: VarDeclaration[],
|
||||||
|
tagModifiers: TagModifier[],
|
||||||
|
): string {
|
||||||
|
return JSON.stringify({ declarations, tagModifiers });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the reactivity engine from the given declarations, then (if the
|
||||||
|
* store is hydrated) rebuild it from the store and seed missing values.
|
||||||
|
* Rebuilding uses both the combined values and their exact bases so tag
|
||||||
|
* modifiers are not double-counted.
|
||||||
|
*/
|
||||||
|
function applyReactivity(
|
||||||
|
declarations: VarDeclaration[],
|
||||||
|
tagModifiers: TagModifier[],
|
||||||
|
): void {
|
||||||
|
initReactivity({ declarations, tagModifiers });
|
||||||
|
_reactivityReady = true;
|
||||||
|
_reactivitySignature = reactivitySignature(declarations, tagModifiers);
|
||||||
|
|
||||||
|
if (hasHydrated()) {
|
||||||
|
rebuildReactivityFromStore(
|
||||||
|
journalStreamState.variables,
|
||||||
|
journalStreamState.variableBases,
|
||||||
|
);
|
||||||
|
seedDeclaredVariables();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the engine matches the currently loaded completions, re-initializing
|
||||||
|
* only when the declaration set has changed since the last apply. No-op while
|
||||||
|
* completions are still loading. Called at bootstrap and before each dispatch,
|
||||||
|
* so a content rescan takes effect without a full reload.
|
||||||
|
*/
|
||||||
|
export function ensureReactivity(): void {
|
||||||
|
const s = completionsState();
|
||||||
|
if (s.status !== "loaded") return;
|
||||||
|
|
||||||
|
const signature = reactivitySignature(s.data.declarations, s.data.tagModifiers);
|
||||||
|
if (_reactivityReady && _reactivitySignature === signature) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
applyReactivity(s.data.declarations, s.data.tagModifiers);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[completions] reactivity init error:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seeding runs after every hydration snapshot so declared variables are
|
||||||
|
// computed against the real session store rather than an empty one.
|
||||||
|
setHydrationListener(() => {
|
||||||
|
try {
|
||||||
|
seedDeclaredVariables();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[completions] seed error:", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Using a top-level IIFE so the promise starts immediately
|
// Using a top-level IIFE so the promise starts immediately
|
||||||
const _initPromise: Promise<void> = (async () => {
|
const _initPromise: Promise<void> = (async () => {
|
||||||
// 1. Try server first (CLI mode)
|
// 1. Try server first (CLI mode)
|
||||||
const serverData = await tryServer();
|
const serverData = await tryServer();
|
||||||
if (serverData) {
|
if (serverData) {
|
||||||
setCompletionsState({ status: "loaded", data: serverData });
|
// Load the registry before publishing the loaded state: dispatching a
|
||||||
|
// command only becomes possible once completions are "loaded", and inline
|
||||||
|
// spark-table CSVs are resolved through this registry at roll time.
|
||||||
await tryServerRegistry();
|
await tryServerRegistry();
|
||||||
try {
|
setCompletionsState({ status: "loaded", data: serverData });
|
||||||
initReactivity({
|
ensureReactivity();
|
||||||
declarations: serverData.declarations,
|
|
||||||
tagModifiers: serverData.tagModifiers,
|
|
||||||
});
|
|
||||||
seedDeclaredVariables();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[completions] reactivity init error:", e);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,15 +232,7 @@ const _initPromise: Promise<void> = (async () => {
|
|||||||
const data = await scanClientSide();
|
const data = await scanClientSide();
|
||||||
if (data.dice.length > 0 || data.links.length > 0) {
|
if (data.dice.length > 0 || data.links.length > 0) {
|
||||||
setCompletionsState({ status: "loaded", data });
|
setCompletionsState({ status: "loaded", data });
|
||||||
try {
|
ensureReactivity();
|
||||||
initReactivity({
|
|
||||||
declarations: data.declarations,
|
|
||||||
tagModifiers: data.tagModifiers,
|
|
||||||
});
|
|
||||||
seedDeclaredVariables();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[completions] reactivity init error:", e);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setCompletionsState({ status: "empty" });
|
setCompletionsState({ status: "empty" });
|
||||||
}
|
}
|
||||||
@@ -194,13 +261,16 @@ export function ensureCompletions(): Promise<void> {
|
|||||||
export function useJournalCompletions(): {
|
export function useJournalCompletions(): {
|
||||||
state: CompletionsState;
|
state: CompletionsState;
|
||||||
data: JournalCompletions;
|
data: JournalCompletions;
|
||||||
|
/** Reactive accessor for runtime variable-engine diagnostics. */
|
||||||
|
diagnostics: () => ReactivityDiagnostic[];
|
||||||
} {
|
} {
|
||||||
const s = completionsState();
|
const s = completionsState();
|
||||||
if (s.status === "loaded") {
|
if (s.status === "loaded") {
|
||||||
return { state: s, data: s.data };
|
return { state: s, data: s.data, diagnostics: reactivityDiagnostics };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
state: s,
|
state: s,
|
||||||
|
diagnostics: reactivityDiagnostics,
|
||||||
data: {
|
data: {
|
||||||
dice: [],
|
dice: [],
|
||||||
links: [],
|
links: [],
|
||||||
@@ -216,8 +286,15 @@ export function useJournalCompletions(): {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function seedDeclaredVariables(): void {
|
function seedDeclaredVariables(): void {
|
||||||
const initial = computeInitialValues(journalStreamState.variables);
|
if (!_reactivityReady) return;
|
||||||
for (const { key, value } of initial) {
|
// Only seed into a live session. Before connect the store is empty and
|
||||||
sendMessage("var", { action: "set", key, value });
|
// publishing derived defaults would clobber the session once it joins.
|
||||||
|
if (!journalStreamState.connected) return;
|
||||||
|
|
||||||
|
const store = journalStreamState.variables;
|
||||||
|
const existing = new Set(Object.keys(store));
|
||||||
|
const initial = computeInitialValues(store, existing);
|
||||||
|
for (const { key, value, base } of initial) {
|
||||||
|
sendMessage("var", { action: "set", key, value, base });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
parseDeclareCsv,
|
parseDeclare,
|
||||||
|
declareSchema,
|
||||||
type VarDeclaration,
|
type VarDeclaration,
|
||||||
type TagModifier,
|
type TagModifier,
|
||||||
type DeclareResult,
|
type DeclareResult,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export { StreamMessageCard } from "./StreamMessage";
|
|||||||
export { ComposePanel } from "./ComposePanel";
|
export { ComposePanel } from "./ComposePanel";
|
||||||
export { JournalInput } from "./JournalInput";
|
export { JournalInput } from "./JournalInput";
|
||||||
export { DynamicForm } from "./DynamicForm";
|
export { DynamicForm } from "./DynamicForm";
|
||||||
export { useJournalCompletions } from "./completions";
|
export { useJournalCompletions, ensureReactivity } from "./completions";
|
||||||
export { dispatchCommand } from "./command-dispatcher";
|
export { dispatchCommand } from "./command-dispatcher";
|
||||||
export type { DispatchContext, DispatchResult } from "./command-dispatcher";
|
export type { DispatchContext, DispatchResult } from "./command-dispatcher";
|
||||||
export { parseInput } from "./command-parser";
|
export { parseInput } from "./command-parser";
|
||||||
@@ -56,11 +56,11 @@ export type { ParsedInput, CompletionItem } from "./command-parser";
|
|||||||
export { buildCompletions } from "./command-completions";
|
export { buildCompletions } from "./command-completions";
|
||||||
export type { CompletionsContext } from "./command-completions";
|
export type { CompletionsContext } from "./command-completions";
|
||||||
export { VariableView } from "./VariableView";
|
export { VariableView } from "./VariableView";
|
||||||
export { parseDeclareCsv } from "./declare-parser";
|
export { parseDeclare, declareSchema } from "./declare-parser";
|
||||||
export type { VarDeclaration, TagModifier } from "./declare-parser";
|
export type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||||
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
|
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
|
||||||
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
|
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
|
||||||
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
|
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues, getReactivityDiagnostics, clearReactivityDiagnostics, onReactivityDiagnostics, resolveVariable } from "./var-reactivity";
|
||||||
export type { VarReactivityState, VariableStore } from "./var-reactivity";
|
export type { VarReactivityState, VariableStore, ReactivityDiagnostic } from "./var-reactivity";
|
||||||
export { JournalContext, useJournalContext } from "./JournalContext";
|
export { JournalContext, useJournalContext } from "./JournalContext";
|
||||||
export type { JournalContextValue } from "./JournalContext";
|
export type { JournalContextValue } from "./JournalContext";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
import { rollFormula } from "../../md-commander/hooks";
|
import { rollFormula } from "../../dice";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
notation: z.string().min(1),
|
notation: z.string().min(1),
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { For } from "solid-js";
|
import { For } from "solid-js";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
import { rollFormula } from "../../md-commander/hooks";
|
|
||||||
import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table";
|
import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table";
|
||||||
import { getIndexedData } from "../../../data-loader/file-index";
|
import { getIndexedData } from "../../../data-loader/file-index";
|
||||||
import { getRegistry } from "../../journal/completions";
|
import { getRegistry } from "../../journal/completions";
|
||||||
|
import { parseMarkdown } from "../../../markdown";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Schema
|
// Schema
|
||||||
@@ -28,12 +28,29 @@ const sparkColumnSchema = z.object({
|
|||||||
header: z.string(),
|
header: z.string(),
|
||||||
slug: z.string(),
|
slug: z.string(),
|
||||||
value: z.string(),
|
value: z.string(),
|
||||||
|
/** Dice total that selected this column's row. */
|
||||||
|
roll: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const sparkResultSchema = z.object({
|
const sparkResultSchema = z.object({
|
||||||
notation: z.string(),
|
notation: z.string(),
|
||||||
columns: z.array(sparkColumnSchema),
|
columns: z.array(sparkColumnSchema),
|
||||||
source: z.string(),
|
source: z.string(),
|
||||||
|
/** True when each column rolled independently. */
|
||||||
|
remix: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Shape of a single dice-engine roll result. */
|
||||||
|
const rollDetailSchema = z.object({
|
||||||
|
total: z.number(),
|
||||||
|
detail: z.string(),
|
||||||
|
plainDetail: z.string(),
|
||||||
|
pools: z.array(
|
||||||
|
z.object({
|
||||||
|
rolls: z.array(z.number()),
|
||||||
|
subtotal: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -43,18 +60,11 @@ const schema = z.object({
|
|||||||
notation: z.string().min(1),
|
notation: z.string().min(1),
|
||||||
/** Resolved spark table columns */
|
/** Resolved spark table columns */
|
||||||
sparkTable: sparkResultSchema,
|
sparkTable: sparkResultSchema,
|
||||||
/** First column's raw dice roll — informational only */
|
/**
|
||||||
rollResult: z.object({
|
* Dice result for the shared roll — absent in remix mode, where each column
|
||||||
total: z.number(),
|
* rolls independently and carries its own `roll` instead.
|
||||||
detail: z.string(),
|
*/
|
||||||
plainDetail: z.string(),
|
rollResult: rollDetailSchema.optional(),
|
||||||
pools: z.array(
|
|
||||||
z.object({
|
|
||||||
rolls: z.array(z.number()),
|
|
||||||
subtotal: z.number(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SparkPayload = z.infer<typeof schema>;
|
export type SparkPayload = z.infer<typeof schema>;
|
||||||
@@ -105,13 +115,13 @@ export async function resolveSparkPayload(raw: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sparkResult = rollSparkTable(meta, { remix: raw.remix });
|
const sparkResult = rollSparkTable(meta, { remix: raw.remix });
|
||||||
const firstRoll = rollFormula(meta.notation);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: raw.key,
|
label: raw.key,
|
||||||
notation: meta.notation,
|
notation: meta.notation,
|
||||||
sparkTable: sparkResult,
|
// Source path lets markdown in the rolled values resolve relative refs.
|
||||||
rollResult: firstRoll.result,
|
sparkTable: { ...sparkResult, source: raw.docPath ?? "" },
|
||||||
|
rollResult: sparkResult.roll,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,35 +137,47 @@ registerMessageType<SparkPayload>({
|
|||||||
defaultPayload: () => ({
|
defaultPayload: () => ({
|
||||||
label: "",
|
label: "",
|
||||||
notation: "d6",
|
notation: "d6",
|
||||||
sparkTable: { notation: "d6", columns: [], source: "" },
|
sparkTable: { notation: "d6", columns: [], source: "", remix: false },
|
||||||
rollResult: { total: 0, detail: "", plainDetail: "", pools: [] },
|
|
||||||
}),
|
}),
|
||||||
render: (p) => {
|
render: (p) => {
|
||||||
const st = p.sparkTable;
|
const st = p.sparkTable;
|
||||||
|
const sharedRoll = p.rollResult?.total;
|
||||||
return (
|
return (
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<span class="text-lg">✨</span>
|
|
||||||
<span class="font-mono text-xs text-purple-600">
|
|
||||||
{st.notation} · spark
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="bg-purple-50 rounded border border-purple-200 overflow-hidden">
|
<div class="bg-purple-50 rounded border border-purple-200 overflow-hidden">
|
||||||
<table class="w-full text-xs">
|
<table class="w-full text-xs">
|
||||||
<tbody>
|
<tbody>
|
||||||
<For each={st.columns}>
|
<For each={st.columns}>
|
||||||
{(col) => (
|
{(col) => {
|
||||||
<tr class="border-t border-purple-100 first:border-t-0">
|
const roll = col.roll ?? sharedRoll;
|
||||||
<td class="px-2 py-1 text-purple-600 font-medium w-1/3">
|
return (
|
||||||
{col.header}
|
<tr class="border-t border-purple-100 first:border-t-0">
|
||||||
</td>
|
<td class="px-2 py-1 text-purple-600 font-medium whitespace-nowrap">
|
||||||
<td class="px-2 py-1 text-gray-800">{col.value}</td>
|
{col.header}
|
||||||
</tr>
|
{roll != null && (
|
||||||
)}
|
<span class="font-mono text-purple-400">
|
||||||
|
{" / "}
|
||||||
|
{st.notation}
|
||||||
|
{" -> "}
|
||||||
|
{roll}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<For each={st.columns}>
|
||||||
|
{(col) => (
|
||||||
|
<div
|
||||||
|
class="px-2 py-1 [&_p]:m-0"
|
||||||
|
innerHTML={parseMarkdown(col.value, st.source)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
* Emitters: gm, player
|
* Emitters: gm, player
|
||||||
* Command: /set $key expression
|
* Command: /set $key expression
|
||||||
*
|
*
|
||||||
* Variable declarations and tag modifiers are authored in ```csv role=declare
|
* Variable declarations and tag modifiers are authored in ```yaml role=declare
|
||||||
* code blocks in markdown documents. The stream carries set/del mutations
|
* code blocks in markdown documents. The stream carries `set` mutations that
|
||||||
* that build up the runtime variable store additively.
|
* build up the runtime variable store additively.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -15,9 +15,13 @@ import { journalSetState } from "../../stores/journalStream";
|
|||||||
import { produce } from "solid-js/store";
|
import { produce } from "solid-js/store";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
action: z.enum(["set", "del"]),
|
action: z.literal("set"),
|
||||||
key: z.string().min(1),
|
key: z.string().min(1),
|
||||||
value: z.string().optional(),
|
value: z.string().optional(),
|
||||||
|
/** The raw base value (before tag modifiers). The store's `variables` map
|
||||||
|
* holds combined values, so this is what the reactivity engine rebuilds
|
||||||
|
* from on hydration. */
|
||||||
|
base: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type VarPayload = z.infer<typeof schema>;
|
export type VarPayload = z.infer<typeof schema>;
|
||||||
@@ -29,16 +33,6 @@ registerMessageType<VarPayload>({
|
|||||||
schema,
|
schema,
|
||||||
defaultPayload: () => ({ action: "set", key: "", value: "" }),
|
defaultPayload: () => ({ action: "set", key: "", value: "" }),
|
||||||
render: (p) => {
|
render: (p) => {
|
||||||
if (p.action === "del") {
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<span class="text-lg">🔢</span>
|
|
||||||
<span class="font-mono text-sm text-gray-500 line-through">
|
|
||||||
{p.key}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="text-lg">🔢</span>
|
<span class="text-lg">🔢</span>
|
||||||
@@ -51,10 +45,13 @@ registerMessageType<VarPayload>({
|
|||||||
reducer: (p) => {
|
reducer: (p) => {
|
||||||
journalSetState(
|
journalSetState(
|
||||||
produce((s) => {
|
produce((s) => {
|
||||||
if (p.action === "del") {
|
if (p.value !== undefined) {
|
||||||
delete s.variables[p.key];
|
|
||||||
} else if (p.value !== undefined) {
|
|
||||||
s.variables[p.key] = p.value;
|
s.variables[p.key] = p.value;
|
||||||
|
if (p.base !== undefined) {
|
||||||
|
s.variableBases[p.key] = p.base;
|
||||||
|
} else {
|
||||||
|
delete s.variableBases[p.key];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ export interface VarReactivityState {
|
|||||||
|
|
||||||
export type VariableStore = Record<string, string>;
|
export type VariableStore = Record<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A variable update produced by the engine: the combined `value` to publish
|
||||||
|
* for display, plus the raw `base` it was derived from. The base is carried on
|
||||||
|
* the stream so a hydrated client can rebuild the engine without having to
|
||||||
|
* reverse-engineer base values out of combined ones.
|
||||||
|
*/
|
||||||
|
export interface VarChange {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
base: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** A single modifier applied to a target variable. */
|
/** A single modifier applied to a target variable. */
|
||||||
export interface ActiveMod {
|
export interface ActiveMod {
|
||||||
tag: string; // "#warrior"
|
tag: string; // "#warrior"
|
||||||
@@ -44,6 +56,26 @@ export interface ActiveMod {
|
|||||||
kind: "numeric" | "tagmap";
|
kind: "numeric" | "tagmap";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A problem detected while evaluating declarations or tag modifiers at
|
||||||
|
* runtime. These are benign to the engine (the offending entry is skipped) but
|
||||||
|
* signal an authoring mistake that would otherwise silently produce a wrong
|
||||||
|
* value, so they are collected for surfacing in the UI.
|
||||||
|
*/
|
||||||
|
export interface ReactivityDiagnostic {
|
||||||
|
kind: "init" | "declaration" | "modifier";
|
||||||
|
/** Human-readable description, already including the offending context. */
|
||||||
|
message: string;
|
||||||
|
/** The variable the diagnostic concerns (e.g. "$hp"). */
|
||||||
|
key?: string;
|
||||||
|
/** The expression that failed to evaluate. */
|
||||||
|
expression?: string;
|
||||||
|
/** For modifiers: the tag whose activation triggered the modifier ("#warrior"). */
|
||||||
|
tag?: string;
|
||||||
|
/** For modifiers: the variable whose tagmap activated the tag ("$class"). */
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tagmap helpers
|
// Tagmap helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -121,6 +153,61 @@ const sourceActivations = new Map<string, Array<{
|
|||||||
/** Set of $vars currently being re-evaluated (cycle guard) */
|
/** Set of $vars currently being re-evaluated (cycle guard) */
|
||||||
const inFlight = new Set<string>();
|
const inFlight = new Set<string>();
|
||||||
|
|
||||||
|
/** Diagnostics collected since the last init, keyed by a stable id to dedupe. */
|
||||||
|
const diagnostics = new Map<string, ReactivityDiagnostic>();
|
||||||
|
|
||||||
|
/** Listeners notified whenever the diagnostic set changes. */
|
||||||
|
const diagnosticListeners = new Set<(diagnostics: ReactivityDiagnostic[]) => void>();
|
||||||
|
|
||||||
|
function errorText(e: unknown): string {
|
||||||
|
return e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticId(d: ReactivityDiagnostic): string {
|
||||||
|
return [d.kind, d.key ?? "", d.expression ?? "", d.source ?? "", d.tag ?? "", d.message].join("\u0000");
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyDiagnostics(): void {
|
||||||
|
const list = [...diagnostics.values()];
|
||||||
|
for (const listener of diagnosticListeners) listener(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a diagnostic, dedupe by identity, and notify listeners. */
|
||||||
|
function reportDiagnostic(d: ReactivityDiagnostic): void {
|
||||||
|
const id = diagnosticId(d);
|
||||||
|
if (diagnostics.has(id)) return;
|
||||||
|
diagnostics.set(id, d);
|
||||||
|
notifyDiagnostics();
|
||||||
|
console.warn(`[var-reactivity] ${d.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostics collected by the engine (bad declarations, modifier expressions,
|
||||||
|
* circular dependencies). Cleared by `initReactivity` so a re-init reports a
|
||||||
|
* fresh set. Repeated failures of the same expression are reported once.
|
||||||
|
*/
|
||||||
|
export function getReactivityDiagnostics(): ReactivityDiagnostic[] {
|
||||||
|
return [...diagnostics.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all collected diagnostics. */
|
||||||
|
export function clearReactivityDiagnostics(): void {
|
||||||
|
if (diagnostics.size === 0) return;
|
||||||
|
diagnostics.clear();
|
||||||
|
notifyDiagnostics();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to diagnostic changes. The listener receives the current list on
|
||||||
|
* every change. Returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
export function onReactivityDiagnostics(
|
||||||
|
listener: (diagnostics: ReactivityDiagnostic[]) => void,
|
||||||
|
): () => void {
|
||||||
|
diagnosticListeners.add(listener);
|
||||||
|
return () => diagnosticListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public API
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -129,42 +216,64 @@ const inFlight = new Set<string>();
|
|||||||
* Initialize (or re-initialize) the reactivity engine from declarations
|
* Initialize (or re-initialize) the reactivity engine from declarations
|
||||||
* and tag modifiers parsed from role=declare blocks.
|
* and tag modifiers parsed from role=declare blocks.
|
||||||
*
|
*
|
||||||
|
* The new state is built into locals and only committed once validation
|
||||||
|
* passes, so a throw (e.g. a circular dependency) leaves the previously
|
||||||
|
* initialized engine intact rather than half-populated.
|
||||||
|
*
|
||||||
* Throws if a circular dependency is detected.
|
* Throws if a circular dependency is detected.
|
||||||
*/
|
*/
|
||||||
export function initReactivity(state: VarReactivityState): void {
|
export function initReactivity(state: VarReactivityState): void {
|
||||||
depGraph = new Map();
|
const nextTagModMap = new Map<
|
||||||
declExprs = new Map();
|
string,
|
||||||
tagModMap = new Map();
|
Array<{ target: string; expression: string; threshold: number }>
|
||||||
baseValues.clear();
|
>();
|
||||||
numericMods.clear();
|
const nextDeclExprs = new Map<string, string>();
|
||||||
tagMapMods.clear();
|
const nextDepGraph = new Map<string, Set<string>>();
|
||||||
sourceActivations.clear();
|
|
||||||
|
|
||||||
// Index tag modifiers
|
// Index tag modifiers
|
||||||
for (const tm of state.tagModifiers) {
|
for (const tm of state.tagModifiers) {
|
||||||
let list = tagModMap.get(tm.tag);
|
let list = nextTagModMap.get(tm.tag);
|
||||||
if (!list) {
|
if (!list) {
|
||||||
list = [];
|
list = [];
|
||||||
tagModMap.set(tm.tag, list);
|
nextTagModMap.set(tm.tag, list);
|
||||||
}
|
}
|
||||||
list.push({ target: tm.target, expression: tm.expression, threshold: tm.threshold });
|
list.push({ target: tm.target, expression: tm.expression, threshold: tm.threshold });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index declarations and build dependency graph
|
// Index declarations and build dependency graph
|
||||||
for (const decl of state.declarations) {
|
for (const decl of state.declarations) {
|
||||||
declExprs.set(decl.key, decl.expression);
|
nextDeclExprs.set(decl.key, decl.expression);
|
||||||
const deps = extractDependencies(decl.expression);
|
const deps = extractDependencies(decl.expression);
|
||||||
for (const dep of deps) {
|
for (const dep of deps) {
|
||||||
let dependents = depGraph.get(dep);
|
let dependents = nextDepGraph.get(dep);
|
||||||
if (!dependents) {
|
if (!dependents) {
|
||||||
dependents = new Set();
|
dependents = new Set();
|
||||||
depGraph.set(dep, dependents);
|
nextDepGraph.set(dep, dependents);
|
||||||
}
|
}
|
||||||
dependents.add(decl.key);
|
dependents.add(decl.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkCircular();
|
try {
|
||||||
|
checkCircular(nextDeclExprs);
|
||||||
|
} catch (e) {
|
||||||
|
reportDiagnostic({
|
||||||
|
kind: "init",
|
||||||
|
message: `Declarations contain an unresolvable dependency: ${errorText(e)}`,
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation passed — commit the new configuration and reset runtime state.
|
||||||
|
depGraph = nextDepGraph;
|
||||||
|
declExprs = nextDeclExprs;
|
||||||
|
tagModMap = nextTagModMap;
|
||||||
|
baseValues.clear();
|
||||||
|
numericMods.clear();
|
||||||
|
tagMapMods.clear();
|
||||||
|
sourceActivations.clear();
|
||||||
|
inFlight.clear();
|
||||||
|
clearReactivityDiagnostics();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract $var names from an expression string. */
|
/** Extract $var names from an expression string. */
|
||||||
@@ -222,6 +331,24 @@ export function getCombined(key: string, fallback?: VariableStore): string {
|
|||||||
return "0";
|
return "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a `$var` to its combined value, distinguishing an unknown variable
|
||||||
|
* (undefined) from a real value of "0". Strict contexts such as `/set` and
|
||||||
|
* `/roll` use this so a typo'd or undeclared reference errors instead of
|
||||||
|
* silently evaluating to 0.
|
||||||
|
*/
|
||||||
|
export function resolveVariable(
|
||||||
|
key: string,
|
||||||
|
fallback?: VariableStore,
|
||||||
|
): string | undefined {
|
||||||
|
const known =
|
||||||
|
baseValues.has(key) ||
|
||||||
|
numericMods.has(key) ||
|
||||||
|
tagMapMods.has(key) ||
|
||||||
|
(fallback !== undefined && fallback[key] !== undefined);
|
||||||
|
return known ? getCombined(key, fallback) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the effective tagmap for a variable (base + tagmap mods).
|
* Get the effective tagmap for a variable (base + tagmap mods).
|
||||||
* Returns null if the variable is not a tagmap variable.
|
* Returns null if the variable is not a tagmap variable.
|
||||||
@@ -265,22 +392,34 @@ export function setBase(key: string, value: string): void {
|
|||||||
* from the hydrated stream variable store. Must be called after replayReducers
|
* from the hydrated stream variable store. Must be called after replayReducers
|
||||||
* so that tag activations are correctly tracked for subsequent cascade
|
* so that tag activations are correctly tracked for subsequent cascade
|
||||||
* computations.
|
* computations.
|
||||||
|
*
|
||||||
|
* The stream store holds *combined* values (base + active modifiers) alongside
|
||||||
|
* the raw `base` each was derived from, so bases are restored exactly rather
|
||||||
|
* than reconstructed by subtracting modifiers back out.
|
||||||
*/
|
*/
|
||||||
export function rebuildReactivityFromStore(variables: VariableStore): void {
|
export function rebuildReactivityFromStore(
|
||||||
|
variables: VariableStore,
|
||||||
|
bases?: VariableStore,
|
||||||
|
): void {
|
||||||
if (!tagModMap) return;
|
if (!tagModMap) return;
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(variables)) {
|
// Reset, then restore exact bases from the store's base map.
|
||||||
if (!baseValues.has(key)) {
|
baseValues.clear();
|
||||||
baseValues.set(key, value);
|
numericMods.clear();
|
||||||
}
|
tagMapMods.clear();
|
||||||
|
sourceActivations.clear();
|
||||||
|
|
||||||
const tagMap = isTagMapValue(value) ? parseTagMap(value) : null;
|
for (const [key, value] of Object.entries(variables)) {
|
||||||
if (tagMap && !sourceActivations.has(key)) {
|
baseValues.set(key, bases?.[key] ?? value);
|
||||||
// Activate all tags in the tagmap
|
}
|
||||||
const added = applyTagMapActivations(key, {}, tagMap, variables);
|
|
||||||
// We don't return the added entries here — they'll be applied
|
// Re-attach tag modifiers from each source's *base* tagmap. Using the
|
||||||
// to the store separately
|
// combined value here would re-trigger a modifier that merely added a tag to
|
||||||
}
|
// the source (feedback), inflating counts on every rebuild.
|
||||||
|
for (const [key, value] of Object.entries(variables)) {
|
||||||
|
const source = bases?.[key] ?? value;
|
||||||
|
const tagMap = isTagMapValue(source) ? parseTagMap(source) : null;
|
||||||
|
if (tagMap) applyTagMapActivations(key, {}, tagMap, variables);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,12 +435,12 @@ export function computeCascade(
|
|||||||
changedVar: string,
|
changedVar: string,
|
||||||
oldValue: string | undefined,
|
oldValue: string | undefined,
|
||||||
currentVars: VariableStore,
|
currentVars: VariableStore,
|
||||||
): Array<{ key: string; value: string }> {
|
): VarChange[] {
|
||||||
if (!depGraph || !declExprs || !tagModMap) {
|
if (!depGraph || !declExprs || !tagModMap) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: Array<{ key: string; value: string }> = [];
|
const results: VarChange[] = [];
|
||||||
|
|
||||||
// ---- Tag activation/deactivation ----
|
// ---- Tag activation/deactivation ----
|
||||||
const newValue = getCombined(changedVar, currentVars);
|
const newValue = getCombined(changedVar, currentVars);
|
||||||
@@ -325,28 +464,35 @@ export function computeCascade(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute initial values for all declared variables.
|
* Compute initial values for all declared variables.
|
||||||
* Called once after initReactivity() to seed the store.
|
* Called after initReactivity() to seed the store.
|
||||||
|
*
|
||||||
|
* @param existing - declared keys already present in the store; these are
|
||||||
|
* skipped so hydrated/overridden session values are not clobbered.
|
||||||
*/
|
*/
|
||||||
export function computeInitialValues(
|
export function computeInitialValues(
|
||||||
currentVars: VariableStore,
|
currentVars: VariableStore,
|
||||||
): Array<{ key: string; value: string }> {
|
existing?: ReadonlySet<string>,
|
||||||
|
): VarChange[] {
|
||||||
if (!declExprs) return [];
|
if (!declExprs) return [];
|
||||||
|
|
||||||
const allKeys = [...declExprs.keys()];
|
const allKeys = [...declExprs.keys()];
|
||||||
const sorted = topoSortAffected(new Set(allKeys));
|
const sorted = topoSortAffected(new Set(allKeys));
|
||||||
|
|
||||||
const results: Array<{ key: string; value: string }> = [];
|
const results: VarChange[] = [];
|
||||||
|
|
||||||
for (const key of sorted) {
|
for (const key of sorted) {
|
||||||
|
// Variables already in the store were seeded (or overridden) previously;
|
||||||
|
// recomputing them would clobber session state on every reload.
|
||||||
|
if (existing?.has(key)) continue;
|
||||||
|
|
||||||
const expr = declExprs.get(key);
|
const expr = declExprs.get(key);
|
||||||
if (!expr) continue;
|
if (!expr) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = evaluateExpression(expr, {
|
const result = evaluateExpression(expr, {
|
||||||
lookup: (name: string) => {
|
// Declarations may reference runtime/external variables; getCombined
|
||||||
const k = "$" + name;
|
// defaults unknown keys to "0" rather than erroring.
|
||||||
return getCombined(k, currentVars);
|
lookup: (name) => getCombined(name, currentVars),
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawValue = exprValueToString(result.value);
|
const rawValue = exprValueToString(result.value);
|
||||||
@@ -366,10 +512,15 @@ export function computeInitialValues(
|
|||||||
// Always emit the combined value for this key
|
// Always emit the combined value for this key
|
||||||
const combined = getCombined(key, currentVars);
|
const combined = getCombined(key, currentVars);
|
||||||
if (!results.some((x) => x.key === key)) {
|
if (!results.some((x) => x.key === key)) {
|
||||||
results.push({ key, value: combined });
|
results.push({ key, value: combined, base: rawValue });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// skip failed evaluations at init time
|
reportDiagnostic({
|
||||||
|
kind: "declaration",
|
||||||
|
key,
|
||||||
|
expression: expr,
|
||||||
|
message: `Declaration ${key} failed to evaluate "${expr}": ${errorText(e)}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,10 +540,10 @@ function applyTagMapActivations(
|
|||||||
oldTagMap: Record<string, number>,
|
oldTagMap: Record<string, number>,
|
||||||
newTagMap: Record<string, number>,
|
newTagMap: Record<string, number>,
|
||||||
fallback: VariableStore,
|
fallback: VariableStore,
|
||||||
): Array<{ key: string; value: string }> {
|
): VarChange[] {
|
||||||
if (!tagModMap) return [];
|
if (!tagModMap) return [];
|
||||||
|
|
||||||
const results: Array<{ key: string; value: string }> = [];
|
const results: VarChange[] = [];
|
||||||
const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]);
|
const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]);
|
||||||
|
|
||||||
for (const tag of allTags) {
|
for (const tag of allTags) {
|
||||||
@@ -416,10 +567,8 @@ function applyTagMapActivations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const evalResult = evaluateExpression(mod.expression, {
|
const evalResult = evaluateExpression(mod.expression, {
|
||||||
lookup: (name: string) => {
|
// Modifier expressions tolerate undefined references (→ "0").
|
||||||
const k = "$" + name;
|
lookup: (name) => getCombined(name, fallback),
|
||||||
return getCombined(k, fallback);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Modifier expressions must evaluate to a number
|
// Modifier expressions must evaluate to a number
|
||||||
@@ -467,14 +616,24 @@ function applyTagMapActivations(
|
|||||||
|
|
||||||
// Emit new combined value
|
// Emit new combined value
|
||||||
const combined = getCombined(mod.target, fallback);
|
const combined = getCombined(mod.target, fallback);
|
||||||
|
const base = baseValues.get(mod.target) ?? combined;
|
||||||
const existing = results.findIndex((r) => r.key === mod.target);
|
const existing = results.findIndex((r) => r.key === mod.target);
|
||||||
if (existing >= 0) {
|
if (existing >= 0) {
|
||||||
results[existing] = { key: mod.target, value: combined };
|
results[existing] = { key: mod.target, value: combined, base };
|
||||||
} else {
|
} else {
|
||||||
results.push({ key: mod.target, value: combined });
|
results.push({ key: mod.target, value: combined, base });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// skip failed modifier
|
reportDiagnostic({
|
||||||
|
kind: "modifier",
|
||||||
|
key: mod.target,
|
||||||
|
expression: mod.expression,
|
||||||
|
tag,
|
||||||
|
source,
|
||||||
|
message:
|
||||||
|
`Modifier ${tag} → ${mod.target} failed to evaluate ` +
|
||||||
|
`"${mod.expression}" (activated by ${source}): ${errorText(e)}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else if (wasActive && !isActive) {
|
} else if (wasActive && !isActive) {
|
||||||
// Deactivate this modifier
|
// Deactivate this modifier
|
||||||
@@ -512,11 +671,12 @@ function applyTagMapActivations(
|
|||||||
|
|
||||||
// Emit updated combined value
|
// Emit updated combined value
|
||||||
const combined = getCombined(entry.target, fallback);
|
const combined = getCombined(entry.target, fallback);
|
||||||
|
const base = baseValues.get(entry.target) ?? combined;
|
||||||
const existing = results.findIndex((r) => r.key === entry.target);
|
const existing = results.findIndex((r) => r.key === entry.target);
|
||||||
if (existing >= 0) {
|
if (existing >= 0) {
|
||||||
results[existing] = { key: entry.target, value: combined };
|
results[existing] = { key: entry.target, value: combined, base };
|
||||||
} else {
|
} else {
|
||||||
results.push({ key: entry.target, value: combined });
|
results.push({ key: entry.target, value: combined, base });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -537,7 +697,7 @@ function applyTagMapActivations(
|
|||||||
function reevaluateDependents(
|
function reevaluateDependents(
|
||||||
changedVar: string,
|
changedVar: string,
|
||||||
fallback: VariableStore,
|
fallback: VariableStore,
|
||||||
): Array<{ key: string; value: string }> {
|
): VarChange[] {
|
||||||
if (!depGraph || !declExprs) return [];
|
if (!depGraph || !declExprs) return [];
|
||||||
|
|
||||||
// Collect all dependents reachable from changedVar (BFS)
|
// Collect all dependents reachable from changedVar (BFS)
|
||||||
@@ -556,7 +716,7 @@ function reevaluateDependents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sorted = topoSortAffected(affected);
|
const sorted = topoSortAffected(affected);
|
||||||
const results: Array<{ key: string; value: string }> = [];
|
const results: VarChange[] = [];
|
||||||
|
|
||||||
for (const key of sorted) {
|
for (const key of sorted) {
|
||||||
if (inFlight.has(key)) {
|
if (inFlight.has(key)) {
|
||||||
@@ -571,10 +731,8 @@ function reevaluateDependents(
|
|||||||
if (!expr) continue;
|
if (!expr) continue;
|
||||||
|
|
||||||
const result = evaluateExpression(expr, {
|
const result = evaluateExpression(expr, {
|
||||||
lookup: (name: string) => {
|
// Declaration re-evaluation stays lenient for undeclared references.
|
||||||
const k = "$" + name;
|
lookup: (name) => getCombined(name, fallback),
|
||||||
return getCombined(k, fallback);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawValue = exprValueToString(result.value);
|
const rawValue = exprValueToString(result.value);
|
||||||
@@ -599,8 +757,16 @@ function reevaluateDependents(
|
|||||||
// Emit combined value
|
// Emit combined value
|
||||||
const combined = getCombined(key, fallback);
|
const combined = getCombined(key, fallback);
|
||||||
if (!results.some((x) => x.key === key)) {
|
if (!results.some((x) => x.key === key)) {
|
||||||
results.push({ key, value: combined });
|
results.push({ key, value: combined, base: rawValue });
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Report the bad declaration but keep evaluating unrelated dependents.
|
||||||
|
reportDiagnostic({
|
||||||
|
kind: "declaration",
|
||||||
|
key,
|
||||||
|
expression: declExprs.get(key),
|
||||||
|
message: `Declaration ${key} failed to re-evaluate "${declExprs.get(key) ?? ""}": ${errorText(e)}`,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
inFlight.delete(key);
|
inFlight.delete(key);
|
||||||
}
|
}
|
||||||
@@ -631,7 +797,10 @@ function topoSortAffected(affected: Set<string>): string[] {
|
|||||||
if (expr) {
|
if (expr) {
|
||||||
const deps = extractDependencies(expr);
|
const deps = extractDependencies(expr);
|
||||||
for (const dep of deps) {
|
for (const dep of deps) {
|
||||||
if (affected.has(dep) || declExprs!.has(dep)) {
|
// Only order within the affected set. Declared dependencies that did
|
||||||
|
// not change must not be pulled in, or reevaluateDependents would
|
||||||
|
// re-publish variables that were never touched.
|
||||||
|
if (affected.has(dep)) {
|
||||||
visit(dep);
|
visit(dep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -649,10 +818,8 @@ function topoSortAffected(affected: Set<string>): string[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkCircular(): void {
|
function checkCircular(exprs: Map<string, string>): void {
|
||||||
if (!declExprs) return;
|
const allKeys = [...exprs.keys()];
|
||||||
|
|
||||||
const allKeys = [...declExprs.keys()];
|
|
||||||
const state = new Map<string, "unvisited" | "visiting" | "visited">();
|
const state = new Map<string, "unvisited" | "visiting" | "visited">();
|
||||||
for (const k of allKeys) state.set(k, "unvisited");
|
for (const k of allKeys) state.set(k, "unvisited");
|
||||||
|
|
||||||
@@ -672,11 +839,11 @@ function checkCircular(): void {
|
|||||||
state.set(key, "visiting");
|
state.set(key, "visiting");
|
||||||
path.push(key);
|
path.push(key);
|
||||||
|
|
||||||
const expr = declExprs!.get(key);
|
const expr = exprs.get(key);
|
||||||
if (expr) {
|
if (expr) {
|
||||||
const deps = extractDependencies(expr);
|
const deps = extractDependencies(expr);
|
||||||
for (const dep of deps) {
|
for (const dep of deps) {
|
||||||
if (declExprs!.has(dep)) {
|
if (exprs.has(dep)) {
|
||||||
dfs(dep);
|
dfs(dep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* - Number literals (integer or decimal)
|
* - Number literals (integer or decimal)
|
||||||
* - Tagmap literals: #warrior:1;#druid:2 (bare #warrior → #warrior:1)
|
* - Tagmap literals: #warrior:1;#druid:2 (bare #warrior → #warrior:1)
|
||||||
* - $var references (resolved via lookup; auto-detects number vs tagmap)
|
* - $var references (resolved via lookup; auto-detects number vs tagmap)
|
||||||
* - Dice patterns: 3d6, 2d8kh1, etc. (delegates to rollFormula)
|
* - Dice patterns: 3d6, 2d8kh1, d20, etc. (delegates to rollFormula)
|
||||||
* - Arithmetic: + - * / (type-checked via registry)
|
* - Arithmetic: + - * / (type-checked via registry)
|
||||||
* - Functions: floor(x), ceil(x), round(x) (numbers only)
|
* - Functions: floor(x), ceil(x), round(x) (numbers only)
|
||||||
* - Parentheses for grouping
|
* - Parentheses for grouping
|
||||||
@@ -16,10 +16,12 @@
|
|||||||
* - Circular variable references (detected by caller)
|
* - Circular variable references (detected by caller)
|
||||||
* - Division by zero
|
* - Division by zero
|
||||||
* - Unknown functions
|
* - Unknown functions
|
||||||
|
* - Unknown variables (lookup returned undefined)
|
||||||
* - Malformed expressions
|
* - Malformed expressions
|
||||||
|
* - Malformed numeric literals (e.g. "1.2.3")
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { rollFormula } from "../md-commander/hooks";
|
import { rollFormula } from "../dice";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -31,8 +33,13 @@ export type ExprValue =
|
|||||||
| { kind: "tagmap"; value: Record<string, number> };
|
| { kind: "tagmap"; value: Record<string, number> };
|
||||||
|
|
||||||
export interface EvalContext {
|
export interface EvalContext {
|
||||||
/** Resolve $var → string (numeric or tagmap serialized form).
|
/** Resolve a variable reference — the full name including the leading `$`
|
||||||
* Return undefined if the variable doesn't exist. */
|
* (e.g. `"$hp"`) — to its serialized value: numeric ("12") or tagmap
|
||||||
|
* ("#warrior:1;#druid:2").
|
||||||
|
*
|
||||||
|
* Return undefined for an unknown variable; the evaluator then throws
|
||||||
|
* `Unknown variable`. Contexts that want a default (e.g. undeclared
|
||||||
|
* variables in declarations resolving to 0) should return a value instead. */
|
||||||
lookup: (varName: string) => string | undefined;
|
lookup: (varName: string) => string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +175,9 @@ interface Token {
|
|||||||
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
|
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
|
||||||
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
|
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
|
||||||
|
|
||||||
|
/** Numeric literal: integer or decimal with digits on both sides of the dot. */
|
||||||
|
const NUMBER_RE = /^\d+(?:\.\d+)?$/;
|
||||||
|
|
||||||
/** Single tagmap entry: "#warrior" or "#warrior:1" */
|
/** Single tagmap entry: "#warrior" or "#warrior:1" */
|
||||||
const TAGMAP_ENTRY_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*)(?::(\d+))?/;
|
const TAGMAP_ENTRY_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*)(?::(\d+))?/;
|
||||||
|
|
||||||
@@ -205,10 +215,32 @@ function tokenize(input: string): Token[] {
|
|||||||
tokens.push({ kind: "number", value: String(rollDice(dice)), raw: dice });
|
tokens.push({ kind: "number", value: String(rollDice(dice)), raw: dice });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// The scanner above consumes digits and dots greedily, so "1.2.3" and
|
||||||
|
// "1." arrive here as a single chunk. Reject them rather than letting
|
||||||
|
// parseFloat silently truncate to a wrong value.
|
||||||
|
if (!NUMBER_RE.test(num)) {
|
||||||
|
throw new Error(`Invalid number literal: "${num}"`);
|
||||||
|
}
|
||||||
tokens.push({ kind: "number", value: num, raw: num });
|
tokens.push({ kind: "number", value: num, raw: num });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dice pattern without a leading count: e.g. "d20", "d6kh1". The number
|
||||||
|
// branch above only catches dice that start with a digit ("3d6"), so bare
|
||||||
|
// forms need their own branch before the generic identifier branch.
|
||||||
|
if (/[dD]/.test(ch) && i + 1 < input.length && /[0-9]/.test(input[i + 1])) {
|
||||||
|
let dice = "";
|
||||||
|
while (i < input.length && /[a-zA-Z0-9]/.test(input[i])) {
|
||||||
|
dice += input[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (!DICE_RE.test(dice)) {
|
||||||
|
throw new Error(`Invalid dice notation: "${dice}"`);
|
||||||
|
}
|
||||||
|
tokens.push({ kind: "number", value: String(rollDice(dice)), raw: dice });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Tagmap literal: #warrior:1;#druid:2 or bare #warrior
|
// Tagmap literal: #warrior:1;#druid:2 or bare #warrior
|
||||||
if (ch === "#") {
|
if (ch === "#") {
|
||||||
let raw = "";
|
let raw = "";
|
||||||
@@ -411,9 +443,9 @@ function parseFactor(
|
|||||||
// Variable reference: $var
|
// Variable reference: $var
|
||||||
if (tok.kind === "var") {
|
if (tok.kind === "var") {
|
||||||
const varName = tok.value; // includes $ prefix
|
const varName = tok.value; // includes $ prefix
|
||||||
const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup
|
const resolved = ctx.lookup(varName);
|
||||||
if (resolved === undefined) {
|
if (resolved === undefined) {
|
||||||
return { value: { kind: "number", value: 0 }, next: pos + 1 };
|
throw new Error(`Unknown variable: ${varName}`);
|
||||||
}
|
}
|
||||||
// Auto-detect: tagmap or numeric
|
// Auto-detect: tagmap or numeric
|
||||||
if (resolved.startsWith("#")) {
|
if (resolved.startsWith("#")) {
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
import { type Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js";
|
|
||||||
import type { TrackerAttribute } from "./types";
|
|
||||||
|
|
||||||
export interface AttributeTooltipProps {
|
|
||||||
position: { x: number; y: number };
|
|
||||||
attributes: Record<string, TrackerAttribute>;
|
|
||||||
classes: string[];
|
|
||||||
onUpdate: (attrName: string, attr: TrackerAttribute) => void;
|
|
||||||
onClassesChange: (classes: string[]) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AttributeTooltip: Component<AttributeTooltipProps> = (props) => {
|
|
||||||
const [position, setPosition] = createSignal(props.position);
|
|
||||||
const [newClass, setNewClass] = createSignal("");
|
|
||||||
|
|
||||||
// 点击外部关闭
|
|
||||||
createEffect(() => {
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('body') && !target.closest(".attribute-tooltip")) {
|
|
||||||
props.onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("click", handleClickOutside);
|
|
||||||
onCleanup(() => document.removeEventListener("click", handleClickOutside));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 处理属性更新
|
|
||||||
const handleUpdate = (attrName: string, value: any) => {
|
|
||||||
const attr = props.attributes[attrName];
|
|
||||||
props.onUpdate(attrName, { ...attr, value });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理滚轮增减(progress 和 count 类型)
|
|
||||||
const handleWheel = (e: WheelEvent, attrName: string, attr: TrackerAttribute) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (attr.type === "count") {
|
|
||||||
const delta = e.deltaY > 0 ? -1 : 1;
|
|
||||||
const newValue = Math.max(0, (attr.value as number) + delta);
|
|
||||||
handleUpdate(attrName, newValue);
|
|
||||||
} else if (attr.type === "progress") {
|
|
||||||
const val = attr.value as { x: number; y: number };
|
|
||||||
const delta = e.deltaY > 0 ? -1 : 1;
|
|
||||||
// 滚轮向上/下调整当前值 x
|
|
||||||
const newX = Math.max(0, Math.min(val.y, val.x + delta));
|
|
||||||
handleUpdate(attrName, { x: newX, y: val.y });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加 class
|
|
||||||
const handleAddClass = () => {
|
|
||||||
const className = newClass().trim();
|
|
||||||
if (className && !props.classes.includes(className)) {
|
|
||||||
props.onClassesChange([...props.classes, className]);
|
|
||||||
setNewClass("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 移除 class
|
|
||||||
const handleRemoveClass = (e: MouseEvent, className: string) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
props.onClassesChange(props.classes.filter((c) => c !== className));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染不同类型的属性编辑器
|
|
||||||
const renderEditor = (attrName: string, attr: TrackerAttribute) => {
|
|
||||||
if (attr.type === "progress") {
|
|
||||||
const val = attr.value as { x: number; y: number };
|
|
||||||
const percentage = val.y > 0 ? (val.x / val.y) * 100 : 0;
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
{/* 进度条 */}
|
|
||||||
<div
|
|
||||||
class="w-full h-4 bg-gray-200 rounded-full overflow-hidden cursor-pointer"
|
|
||||||
title="滚动鼠标滚轮增减"
|
|
||||||
onWheel={(e) => handleWheel(e, attrName, attr)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="h-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-200"
|
|
||||||
style={{ width: `${Math.min(100, percentage)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* 输入框 */}
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={val.x}
|
|
||||||
onChange={(e) => handleUpdate(attrName, { x: parseInt(e.target.value) || 0, y: val.y })}
|
|
||||||
onWheel={(e) => handleWheel(e, attrName, attr)}
|
|
||||||
class="w-14 px-1 py-0.5 border border-gray-300 rounded text-center text-sm"
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
<span class="text-gray-500">/</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={val.y}
|
|
||||||
onChange={(e) => handleUpdate(attrName, { x: val.x, y: parseInt(e.target.value) || 0 })}
|
|
||||||
class="w-14 px-1 py-0.5 border border-gray-300 rounded text-center text-sm"
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attr.type === "count") {
|
|
||||||
const value = attr.value as number;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1"
|
|
||||||
onWheel={(e) => handleWheel(e, attrName, attr)}
|
|
||||||
title="滚动鼠标滚轮增减"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="w-7 h-7 flex items-center justify-center bg-gray-200 hover:bg-gray-300 rounded text-lg font-bold transition-colors"
|
|
||||||
onClick={() => handleUpdate(attrName, value - 1)}
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => handleUpdate(attrName, parseInt(e.target.value) || 0)}
|
|
||||||
onWheel={(e) => handleWheel(e, attrName, attr)}
|
|
||||||
class="w-12 px-1 py-0.5 border border-gray-300 rounded text-center text-sm"
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="w-7 h-7 flex items-center justify-center bg-gray-200 hover:bg-gray-300 rounded text-lg font-bold transition-colors"
|
|
||||||
onClick={() => handleUpdate(attrName, value + 1)}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attr.type === "string") {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={attr.value as string}
|
|
||||||
onChange={(e) => handleUpdate(attrName, e.target.value)}
|
|
||||||
class="w-full px-2 py-1 border border-gray-300 rounded text-sm"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <span class="text-gray-500">未知类型</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="attribute-tooltip fixed z-50 bg-white border border-gray-300 rounded-lg shadow-xl p-3 min-w-[150px]"
|
|
||||||
style={{
|
|
||||||
left: `${position().x}px`,
|
|
||||||
top: `${position().y}px`,
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between mb-2 pb-2 border-b border-gray-200">
|
|
||||||
<span class="font-bold text-gray-700 text-sm">编辑属性</span>
|
|
||||||
<button
|
|
||||||
class="text-gray-400 hover:text-gray-600 text-lg leading-none"
|
|
||||||
onClick={props.onClose}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-3">
|
|
||||||
{/* Class 列表编辑器 */}
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label class="text-xs text-gray-500 font-medium">Classes</label>
|
|
||||||
<div class="flex flex-wrap gap-1 mb-1">
|
|
||||||
<For each={props.classes}>
|
|
||||||
{(className) => (
|
|
||||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">
|
|
||||||
.{className}
|
|
||||||
<button
|
|
||||||
class="text-blue-400 hover:text-blue-600 font-bold leading-none"
|
|
||||||
onClick={(e) => handleRemoveClass(e, className)}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newClass()}
|
|
||||||
onInput={(e) => setNewClass((e.target as HTMLInputElement).value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleAddClass();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="添加 class..."
|
|
||||||
class="flex-1 px-2 py-1 border border-gray-300 rounded text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 bg-gray-200 hover:bg-gray-300 rounded text-sm font-medium transition-colors"
|
|
||||||
onClick={handleAddClass}
|
|
||||||
>
|
|
||||||
添加
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 属性编辑器 */}
|
|
||||||
<For each={Object.entries(props.attributes)}>
|
|
||||||
{([name, attr]) => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label class="text-xs text-gray-500 font-medium">{name}</label>
|
|
||||||
{renderEditor(name, attr)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { type Component, For, Show } from "solid-js";
|
|
||||||
import type { CommanderEntry } from "./types";
|
|
||||||
import { getResultClass } from "./hooks";
|
|
||||||
|
|
||||||
export interface CommanderEntriesProps {
|
|
||||||
entries: () => CommanderEntry[];
|
|
||||||
onCommandClick?: (command: string) => void;
|
|
||||||
loading?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CommanderEntries: Component<CommanderEntriesProps> = (props) => {
|
|
||||||
let containerRef: HTMLDivElement | undefined;
|
|
||||||
|
|
||||||
const handleCommandClick = (command: string) => {
|
|
||||||
if (props.onCommandClick) {
|
|
||||||
props.onCommandClick(command);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
class="commander-entries flex-1 overflow-auto p-3 bg-white space-y-2"
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={props.loading}
|
|
||||||
fallback={
|
|
||||||
<Show
|
|
||||||
when={props.error}
|
|
||||||
fallback={
|
|
||||||
<Show
|
|
||||||
when={props.entries().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="text-gray-400 text-center py-8">暂无命令执行记录</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={props.entries()}>
|
|
||||||
{(entry) => (
|
|
||||||
<div class="border-l-2 border-gray-300 pl-3 py-1">
|
|
||||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
|
||||||
<span
|
|
||||||
class="font-mono cursor-pointer hover:text-blue-600 hover:underline truncate max-w-64"
|
|
||||||
onClick={() => handleCommandClick(entry.command)}
|
|
||||||
title="点击复制到输入框"
|
|
||||||
>
|
|
||||||
{entry.command}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{entry.timestamp.toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class={`text-sm whitespace-pre-wrap ${getResultClass(entry.result.type)}`}
|
|
||||||
innerHTML={entry.result.isHtml ? entry.result.message : undefined}
|
|
||||||
>
|
|
||||||
{!entry.result.isHtml && entry.result.message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="text-red-500 text-center py-8">{props.error}</div>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-center h-full">
|
|
||||||
<div class="text-gray-500 flex items-center gap-2">
|
|
||||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
|
|
||||||
<span>加载命令模板中...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import {type Component, For, Show, createEffect, on} from "solid-js";
|
|
||||||
import type { CompletionItem } from "./types";
|
|
||||||
|
|
||||||
export interface CommanderInputProps {
|
|
||||||
placeholder?: string;
|
|
||||||
inputValue: () => string;
|
|
||||||
onInput: (e: Event) => void;
|
|
||||||
onKeyDown: (e: KeyboardEvent) => void;
|
|
||||||
onFocus: () => void;
|
|
||||||
onBlur: () => void;
|
|
||||||
onSubmit: () => void;
|
|
||||||
showCompletions: () => boolean;
|
|
||||||
completions: () => CompletionItem[];
|
|
||||||
selectedCompletion: () => number;
|
|
||||||
onSelectCompletion: (idx: number) => void;
|
|
||||||
onAcceptCompletion: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CommanderInput: Component<CommanderInputProps> = (props) => {
|
|
||||||
let containerRef: HTMLDivElement | undefined;
|
|
||||||
|
|
||||||
// 当选中项变化时自动滚动到可见区域
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => props.selectedCompletion(),
|
|
||||||
() => {
|
|
||||||
if (containerRef) {
|
|
||||||
const selectedItem = containerRef.querySelector('[data-selected="true"]');
|
|
||||||
if (selectedItem) {
|
|
||||||
selectedItem.scrollIntoView({ block: "nearest" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const maxVisible = 3;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex items-center border-b border-gray-300 bg-gray-50 px-3 py-2">
|
|
||||||
<span class="text-gray-500 mr-2">❯</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={props.inputValue()}
|
|
||||||
onInput={props.onInput}
|
|
||||||
onKeyDown={props.onKeyDown}
|
|
||||||
onFocus={props.onFocus}
|
|
||||||
onBlur={props.onBlur}
|
|
||||||
placeholder={props.placeholder || "输入命令..."}
|
|
||||||
class="flex-1 bg-transparent outline-none text-gray-800"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={props.onSubmit}
|
|
||||||
class="ml-2 px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
执行
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 自动补全下拉框 - 向上弹出 */}
|
|
||||||
<Show when={props.showCompletions() && props.completions().length > 0}>
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
class="absolute left-0 bottom-full w-full bg-white border border-gray-300 shadow-lg mb-1 overflow-auto"
|
|
||||||
style={{ "max-height": `${maxVisible * 40}px` }}
|
|
||||||
>
|
|
||||||
<For each={props.completions()}>{(comp, idx) => (
|
|
||||||
<div
|
|
||||||
data-selected={idx() === props.selectedCompletion()}
|
|
||||||
class={`px-3 py-2 cursor-pointer flex justify-between items-center ${
|
|
||||||
idx() === props.selectedCompletion()
|
|
||||||
? "bg-blue-100"
|
|
||||||
: "hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
props.onSelectCompletion(idx());
|
|
||||||
props.onAcceptCompletion();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
class={`text-xs px-1 rounded ${
|
|
||||||
comp.type === "command"
|
|
||||||
? "bg-purple-100 text-purple-700"
|
|
||||||
: comp.type === "option"
|
|
||||||
? "bg-green-100 text-green-700"
|
|
||||||
: "bg-gray-100 text-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{comp.type}
|
|
||||||
</span>
|
|
||||||
<span class="font-mono text-sm">{comp.label}</span>
|
|
||||||
</div>
|
|
||||||
<Show when={comp.description}>
|
|
||||||
<span class="text-xs text-gray-500">{comp.description}</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { type Component } from "solid-js";
|
|
||||||
import type { TrackerViewMode } from "./types";
|
|
||||||
|
|
||||||
export interface TabBarProps {
|
|
||||||
mode: () => TrackerViewMode;
|
|
||||||
onModeChange: (mode: TrackerViewMode) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TabBar: Component<TabBarProps> = (props) => {
|
|
||||||
return (
|
|
||||||
<div class="flex lg:hidden border-b border-gray-300 bg-gray-50">
|
|
||||||
<button
|
|
||||||
class={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
|
||||||
props.mode() === "history"
|
|
||||||
? "bg-white text-blue-600 border-b-2 border-blue-600"
|
|
||||||
: "text-gray-600 hover:text-gray-800 hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
onClick={() => props.onModeChange("history")}
|
|
||||||
>
|
|
||||||
📜 历史
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
|
||||||
props.mode() === "tracker"
|
|
||||||
? "bg-white text-blue-600 border-b-2 border-blue-600"
|
|
||||||
: "text-gray-600 hover:text-gray-800 hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
onClick={() => props.onModeChange("tracker")}
|
|
||||||
>
|
|
||||||
📋 追踪
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import { type Component, For, Show, createSignal } from "solid-js";
|
|
||||||
import type { TrackerItem, TrackerAttribute } from "./types";
|
|
||||||
import { AttributeTooltip } from "./AttributeTooltip";
|
|
||||||
|
|
||||||
export interface TrackerViewProps {
|
|
||||||
items: () => TrackerItem[];
|
|
||||||
onEditAttribute?: (index: number, attrName: string, attr: TrackerAttribute) => void;
|
|
||||||
onClassesChange?: (index: number, classes: string[]) => void;
|
|
||||||
onRemoveClass?: (index: number, className: string) => void;
|
|
||||||
onMoveUp?: (index: number) => void;
|
|
||||||
onMoveDown?: (index: number) => void;
|
|
||||||
onRemove?: (index: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TrackerView: Component<TrackerViewProps> = (props) => {
|
|
||||||
const [editingItem, setEditingItem] = createSignal<{
|
|
||||||
index: number;
|
|
||||||
position: { x: number; y: number };
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const formatAttributeValue = (attr: TrackerAttribute): string => {
|
|
||||||
if (attr.type === "progress") {
|
|
||||||
const val = attr.value as { x: number; y: number };
|
|
||||||
return `${val.x}/${val.y}`;
|
|
||||||
}
|
|
||||||
if (attr.type === "count") {
|
|
||||||
return String(attr.value);
|
|
||||||
}
|
|
||||||
return String(attr.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatAttributeShort = (name: string, attr: TrackerAttribute): string => {
|
|
||||||
const val = formatAttributeValue(attr);
|
|
||||||
return `${name}=${val}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理滚轮增减(progress 和 count 类型)
|
|
||||||
const handleWheel = (e: WheelEvent, index: number, attrName: string, attr: TrackerAttribute) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (attr.type === "count") {
|
|
||||||
const delta = e.deltaY > 0 ? -1 : 1;
|
|
||||||
const newValue = Math.max(0, (attr.value as number) + delta);
|
|
||||||
props.onEditAttribute?.(index, attrName, { ...attr, value: newValue });
|
|
||||||
} else if (attr.type === "progress") {
|
|
||||||
const val = attr.value as { x: number; y: number };
|
|
||||||
const delta = e.deltaY > 0 ? -1 : 1;
|
|
||||||
const newX = Math.max(0, Math.min(val.y, val.x + delta));
|
|
||||||
props.onEditAttribute?.(index, attrName, { ...attr, value: { x: newX, y: val.y } });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTagClick = (e: MouseEvent, index: number) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
||||||
setEditingItem({
|
|
||||||
index,
|
|
||||||
position: {
|
|
||||||
x: rect.left,
|
|
||||||
y: rect.bottom + 5,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateAttribute = (attrName: string, attr: TrackerAttribute) => {
|
|
||||||
const data = editingItem();
|
|
||||||
if (data) {
|
|
||||||
props.onEditAttribute?.(data.index, attrName, attr);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClassesChange = (classes: string[]) => {
|
|
||||||
const data = editingItem();
|
|
||||||
if (data) {
|
|
||||||
props.onClassesChange?.(data.index, classes);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="tracker-view flex-1 overflow-auto p-3 bg-white">
|
|
||||||
<Show
|
|
||||||
when={props.items().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="text-gray-400 text-center py-8">暂无追踪项目</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<For each={props.items()}>
|
|
||||||
{(item, index) => (
|
|
||||||
<div class="border border-gray-200 rounded-lg p-2 bg-gray-50">
|
|
||||||
{/* 第一行:tag#id.class + 操作按钮 */}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
|
|
||||||
<button
|
|
||||||
class="font-bold text-gray-800 whitespace-nowrap hover:text-blue-600 hover:underline cursor-pointer transition-colors"
|
|
||||||
title="点击编辑属性"
|
|
||||||
onClick={(e) => handleTagClick(e, index())}
|
|
||||||
>
|
|
||||||
{item.tag}
|
|
||||||
</button>
|
|
||||||
<Show when={item.id}>
|
|
||||||
<span class="text-xs text-purple-600 font-mono whitespace-nowrap">#{item.id}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.classes.length > 0}>
|
|
||||||
<span class="text-xs text-blue-600 font-mono whitespace-nowrap">.{item.classes.join(".")}</span>
|
|
||||||
</Show>
|
|
||||||
{/* 属性简写显示 */}
|
|
||||||
<Show when={Object.keys(item.attributes).length > 0}>
|
|
||||||
<span class="text-xs text-gray-500 font-mono whitespace-nowrap truncate flex gap-2">
|
|
||||||
<For each={Object.entries(item.attributes)}>
|
|
||||||
{([name, attr]) => (
|
|
||||||
<span
|
|
||||||
class="inline-block cursor-pointer hover:text-blue-600 hover:underline"
|
|
||||||
title={`${name}: ${formatAttributeValue(attr)} - 滚动鼠标滚轮增减`}
|
|
||||||
onWheel={(e) => handleWheel(e, index(), name, attr)}
|
|
||||||
>
|
|
||||||
{formatAttributeShort(name, attr)}{" "}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1 flex-shrink-0 ml-2">
|
|
||||||
<button
|
|
||||||
class="p-1 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded text-xs"
|
|
||||||
title="上移"
|
|
||||||
onClick={() => props.onMoveUp?.(index())}
|
|
||||||
>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="p-1 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded text-xs"
|
|
||||||
title="下移"
|
|
||||||
onClick={() => props.onMoveDown?.(index())}
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="p-1 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded text-xs"
|
|
||||||
title="移除"
|
|
||||||
onClick={() => props.onRemove?.(index())}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* 属性编辑工具提示 */}
|
|
||||||
<Show when={editingItem()}>
|
|
||||||
<AttributeTooltip
|
|
||||||
position={editingItem()!.position}
|
|
||||||
attributes={props.items()[editingItem()!.index]?.attributes || {}}
|
|
||||||
classes={props.items()[editingItem()!.index]?.classes || []}
|
|
||||||
onUpdate={handleUpdateAttribute}
|
|
||||||
onClassesChange={handleClassesChange}
|
|
||||||
onClose={() => setEditingItem(null)}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { MdCommanderCommand } from "../types";
|
|
||||||
|
|
||||||
export const clearCommand: MdCommanderCommand = {
|
|
||||||
command: "clear",
|
|
||||||
description: "清空命令历史",
|
|
||||||
handler: () => ({
|
|
||||||
message: "命令历史已清空",
|
|
||||||
type: "success",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { MdCommanderCommand, MdCommanderCommandMap } from "../types";
|
|
||||||
|
|
||||||
export function setupHelpCommand(commands: MdCommanderCommandMap): MdCommanderCommand {
|
|
||||||
return {
|
|
||||||
command: "help",
|
|
||||||
description: "显示帮助信息或特定命令的帮助",
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: "cmd",
|
|
||||||
description: "要查询的命令名",
|
|
||||||
type: "enum",
|
|
||||||
values: Object.keys(commands).filter((k) => k !== "help"),
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
handler: (args, cmds) => {
|
|
||||||
const cmdName = args.params.cmd;
|
|
||||||
if (cmdName && cmds?.[cmdName]) {
|
|
||||||
return {
|
|
||||||
message: `命令:${cmdName}\n描述:${cmds[cmdName]?.description || "无描述"}`,
|
|
||||||
type: "info",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const cmdList = Object.keys(cmds || {}).filter((k) => k !== "help").join(", ");
|
|
||||||
return {
|
|
||||||
message: `可用命令:${cmdList}`,
|
|
||||||
type: "info",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { setupHelpCommand } from "./help";
|
|
||||||
export { clearCommand } from "./clear";
|
|
||||||
export { rollCommand } from "./roll";
|
|
||||||
export { trackCommand, untrackCommand, listTrackCommand } from "./tracker";
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { MdCommanderCommand } from "../types";
|
|
||||||
import { rollSimple } from "../hooks/useDiceRoller";
|
|
||||||
|
|
||||||
export const rollCommand: MdCommanderCommand = {
|
|
||||||
command: "roll",
|
|
||||||
description: "掷骰子 - 支持骰池、修饰符和组合",
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: "formula",
|
|
||||||
description: "骰子公式,如 3d6+{d4,d8}kh2-5",
|
|
||||||
type: "string",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
handler: (args) => {
|
|
||||||
const formula = args.params.formula || "1d6";
|
|
||||||
const result = rollSimple(formula);
|
|
||||||
return {
|
|
||||||
message: result.text,
|
|
||||||
isHtml: result.isHtml,
|
|
||||||
type: result.text.startsWith("错误") ? "error" : "success",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import type { MdCommanderCommand } from "../types";
|
|
||||||
import { parseEmmet } from "../utils";
|
|
||||||
import { addTrackerItem, removeTrackerItem, getTrackerItems, findTrackerItem } from "../stores";
|
|
||||||
|
|
||||||
export const trackCommand: MdCommanderCommand = {
|
|
||||||
command: "track",
|
|
||||||
description: "添加一个新的追踪项目 - 支持 Emmet 语法:track npc#john.dwarf.warrior[hp=4/4 ac=15]",
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: "emmet",
|
|
||||||
description: "Emmet 格式的追踪项目:tag#class1.class2[attr=value]",
|
|
||||||
type: "string",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
handler: (args, commands) => {
|
|
||||||
const emmet = args.params.emmet;
|
|
||||||
if (!emmet) {
|
|
||||||
return { message: "错误:缺少 Emmet 参数", type: "error" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseEmmet(emmet);
|
|
||||||
|
|
||||||
if (!parsed.tag) {
|
|
||||||
return { message: "错误:缺少 tag", type: "error" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直接添加追踪项目
|
|
||||||
addTrackerItem(parsed);
|
|
||||||
|
|
||||||
const idStr = parsed.id ? ` #${parsed.id}` : "";
|
|
||||||
const classStr = parsed.classes.length > 0 ? `.${parsed.classes.join(".")}` : "";
|
|
||||||
const attrCount = Object.keys(parsed.attributes).length;
|
|
||||||
const attrStr = attrCount > 0 ? `[${attrCount} 个属性]` : "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: `已添加追踪项目:${parsed.tag}${idStr}${classStr}${attrStr}`,
|
|
||||||
type: "success",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const untrackCommand: MdCommanderCommand = {
|
|
||||||
command: "untrack",
|
|
||||||
description: "移除一个追踪项目 - 支持 Emmet 语法:untrack #g2 或 untrack goblin.warrior",
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: "emmet",
|
|
||||||
description: "Emmet 格式:#id 或 tag.class",
|
|
||||||
type: "string",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
handler: (args) => {
|
|
||||||
const emmet = args.params.emmet;
|
|
||||||
if (!emmet) {
|
|
||||||
return { message: "错误:缺少 Emmet 参数", type: "error" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const found = findTrackerItem(emmet);
|
|
||||||
if (!found) {
|
|
||||||
return { message: `未找到匹配的追踪项目:${emmet}`, type: "error" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = removeTrackerItem(emmet);
|
|
||||||
if (!success) {
|
|
||||||
return { message: `移除失败:${emmet}`, type: "error" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { message: `已移除追踪项目:${found.tag}${found.id ? '#' + found.id : ''}`, type: "success" };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listTrackCommand: MdCommanderCommand = {
|
|
||||||
command: "list",
|
|
||||||
description: "列出所有追踪项目",
|
|
||||||
handler: (args, commands) => {
|
|
||||||
const items = getTrackerItems();
|
|
||||||
if (items.length === 0) {
|
|
||||||
return { message: "暂无追踪项目", type: "info" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = items.map((i) => `${i.tag}${i.id ? "#" + i.id : ""}${i.classes.length > 0 ? "." + i.classes.join(".") : ""}`).join("\n");
|
|
||||||
return { message: `追踪项目:\n${list}`, type: "info" };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import type { MdCommanderCommandMap, CompletionItem } from "../types";
|
|
||||||
|
|
||||||
export interface ParsedCommand {
|
|
||||||
command?: string;
|
|
||||||
params: Record<string, string>;
|
|
||||||
options: Record<string, string>;
|
|
||||||
incompleteParam?: { index: number; value: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析命令行输入
|
|
||||||
*/
|
|
||||||
export function parseInput(input: string, commands?: MdCommanderCommandMap): ParsedCommand {
|
|
||||||
const result: ParsedCommand = { params: {}, options: {} };
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed) return result;
|
|
||||||
|
|
||||||
const parts = trimmed.split(/\s+/);
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
// 获取命令
|
|
||||||
if (parts[0] && !parts[0].startsWith("-")) {
|
|
||||||
result.command = parts[0];
|
|
||||||
i = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取命令的参数定义
|
|
||||||
const cmd = result.command ? commands?.[result.command] : undefined;
|
|
||||||
const paramDefs = cmd?.parameters || [];
|
|
||||||
let paramIndex = 0;
|
|
||||||
|
|
||||||
// 解析参数和选项
|
|
||||||
while (i < parts.length) {
|
|
||||||
const part = parts[i];
|
|
||||||
|
|
||||||
if (part.startsWith("--")) {
|
|
||||||
const eqIndex = part.indexOf("=");
|
|
||||||
if (eqIndex !== -1) {
|
|
||||||
result.options[part.slice(2, eqIndex)] = part.slice(eqIndex + 1);
|
|
||||||
} else {
|
|
||||||
const key = part.slice(2);
|
|
||||||
if (i + 1 < parts.length && !parts[i + 1].startsWith("-")) {
|
|
||||||
result.options[key] = parts[i + 1];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (part.startsWith("-") && part.length === 2) {
|
|
||||||
const key = part.slice(1);
|
|
||||||
if (i + 1 < parts.length && !parts[i + 1].startsWith("-")) {
|
|
||||||
result.options[key] = parts[i + 1];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
} else if (paramIndex < paramDefs.length) {
|
|
||||||
result.params[paramDefs[paramIndex].name] = part;
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有未完成的位置参数
|
|
||||||
if (paramIndex < paramDefs.length) {
|
|
||||||
const lastPart = parts[parts.length - 1];
|
|
||||||
if (lastPart && !lastPart.startsWith("-") && parts.length > 1) {
|
|
||||||
result.incompleteParam = { index: paramIndex, value: lastPart };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取自动补全建议
|
|
||||||
*/
|
|
||||||
export function getCompletions(input: string, commands: MdCommanderCommandMap): CompletionItem[] {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
|
|
||||||
// 空输入时返回所有命令
|
|
||||||
if (!trimmed || /^\s*$/.test(trimmed)) {
|
|
||||||
return Object.values(commands).map((cmd) => ({
|
|
||||||
label: cmd.command,
|
|
||||||
type: "command",
|
|
||||||
description: cmd.description,
|
|
||||||
insertText: cmd.command,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseInput(trimmed, commands);
|
|
||||||
|
|
||||||
// 命令补全
|
|
||||||
if (!parsed.command || !commands[parsed.command]) {
|
|
||||||
return Object.values(commands)
|
|
||||||
.filter((cmd) => cmd.command.startsWith(parsed.command || ""))
|
|
||||||
.map((cmd) => ({
|
|
||||||
label: cmd.command,
|
|
||||||
type: "command",
|
|
||||||
description: cmd.description,
|
|
||||||
insertText: cmd.command,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const cmd = commands[parsed.command];
|
|
||||||
if (!cmd) return [];
|
|
||||||
|
|
||||||
const paramDefs = cmd.parameters || [];
|
|
||||||
const usedParams = Object.keys(parsed.params);
|
|
||||||
|
|
||||||
// 判断是否正在输入最后一个参数
|
|
||||||
const isTypingLastParam = paramDefs.length === usedParams.length &&
|
|
||||||
trimmed.length > 0 &&
|
|
||||||
!trimmed.endsWith(" ") &&
|
|
||||||
!trimmed.split(/\s+/)[trimmed.split(/\s+/).length - 1].startsWith("-");
|
|
||||||
|
|
||||||
// 参数补全
|
|
||||||
if (paramDefs.length > usedParams.length || parsed.incompleteParam || isTypingLastParam) {
|
|
||||||
let paramIndex = parsed.incompleteParam?.index ?? (isTypingLastParam ? paramDefs.length - 1 : usedParams.length);
|
|
||||||
const paramDef = paramDefs[paramIndex];
|
|
||||||
if (!paramDef) return [];
|
|
||||||
|
|
||||||
let currentValue = parsed.incompleteParam?.value || "";
|
|
||||||
if (isTypingLastParam && !parsed.incompleteParam) {
|
|
||||||
const parts = trimmed.split(/\s+/);
|
|
||||||
currentValue = parts[parts.length - 1] || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模板补全
|
|
||||||
if (paramDef.templates) {
|
|
||||||
return paramDef.templates
|
|
||||||
.filter((t) => t.label.toLowerCase().includes(currentValue.toLowerCase()))
|
|
||||||
.map((t) => ({
|
|
||||||
label: t.label,
|
|
||||||
type: "value",
|
|
||||||
description: t.description,
|
|
||||||
insertText: t.insertText,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 枚举补全
|
|
||||||
if (paramDef.type === "enum" && paramDef.values) {
|
|
||||||
return paramDef.values
|
|
||||||
.filter((v) => v.startsWith(currentValue))
|
|
||||||
.map((v) => ({
|
|
||||||
label: v,
|
|
||||||
type: "value",
|
|
||||||
description: paramDef.description,
|
|
||||||
insertText: v,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他类型显示提示
|
|
||||||
return [{
|
|
||||||
label: `<${paramDef.name}>`,
|
|
||||||
type: "value",
|
|
||||||
description: `${paramDef.type}${paramDef.required !== false ? " (必填)" : ""}: ${paramDef.description || ""}`,
|
|
||||||
insertText: "",
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选项补全
|
|
||||||
if (!cmd.options) return [];
|
|
||||||
|
|
||||||
const usedOptions = Object.keys(parsed.options);
|
|
||||||
return Object.values(cmd.options)
|
|
||||||
.filter((opt) => !usedOptions.includes(opt.option))
|
|
||||||
.map((opt) => ({
|
|
||||||
label: `--${opt.option}`,
|
|
||||||
type: "option",
|
|
||||||
description: opt.description,
|
|
||||||
insertText: `--${opt.option}=${opt.type === "boolean" ? "" : ""}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据结果类型获取 CSS 类名
|
|
||||||
*/
|
|
||||||
export function getResultClass(type?: "success" | "error" | "warning" | "info"): string {
|
|
||||||
switch (type) {
|
|
||||||
case "success": return "text-green-600";
|
|
||||||
case "error": return "text-red-600";
|
|
||||||
case "warning": return "text-yellow-600";
|
|
||||||
default: return "text-blue-600";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export type {
|
|
||||||
DieResult,
|
|
||||||
DicePool,
|
|
||||||
RollResult,
|
|
||||||
DicePoolResult,
|
|
||||||
Token,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export { tokenize, parse } from "./parser";
|
|
||||||
export { rollDie, rollDicePool, applyModifier, roll } from "./roller";
|
|
||||||
export { testDiceEngine } from "./test";
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { tokenize, parse } from "./parser";
|
|
||||||
import { rollDicePool, roll } from "./roller";
|
|
||||||
import type { DicePool } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 骰子引擎测试
|
|
||||||
* 在浏览器控制台运行:window.testDiceEngine()
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function testDiceEngine() {
|
|
||||||
console.log("=== 骰子引擎测试 ===\n");
|
|
||||||
|
|
||||||
// 测试用例
|
|
||||||
const testCases: Array<{
|
|
||||||
name: string;
|
|
||||||
formula: string;
|
|
||||||
expected: {
|
|
||||||
hasPools: number;
|
|
||||||
hasModifier?: boolean;
|
|
||||||
};
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
name: "标准骰子 3d6",
|
|
||||||
formula: "3d6",
|
|
||||||
expected: { hasPools: 1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "单个骰子 d20",
|
|
||||||
formula: "d20",
|
|
||||||
expected: { hasPools: 1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "骰池字面量 {d4,d8}",
|
|
||||||
formula: "{d4,d8}",
|
|
||||||
expected: { hasPools: 1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "带修饰符 3d6kh2",
|
|
||||||
formula: "3d6kh2",
|
|
||||||
expected: { hasPools: 1, hasModifier: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "带修饰符 4d6dl1",
|
|
||||||
formula: "4d6dl1",
|
|
||||||
expected: { hasPools: 1, hasModifier: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "组合骰池 3d6+5",
|
|
||||||
formula: "3d6+5",
|
|
||||||
expected: { hasPools: 2 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "负数组合 3d6-2",
|
|
||||||
formula: "3d6-2",
|
|
||||||
expected: { hasPools: 2 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "复杂公式 3d6+{d4,d8}kh2-5",
|
|
||||||
formula: "3d6+{d4,d8}kh2-5",
|
|
||||||
expected: { hasPools: 4, hasModifier: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "多个骰池 {d6,d8}+{d10,d12}",
|
|
||||||
formula: "{d6,d8}+{d10,d12}",
|
|
||||||
expected: { hasPools: 2 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Keep Lowest 5d6kl3",
|
|
||||||
formula: "5d6kl3",
|
|
||||||
expected: { hasPools: 1, hasModifier: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Drop Highest 4d6dh1",
|
|
||||||
formula: "4d6dh1",
|
|
||||||
expected: { hasPools: 1, hasModifier: true },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let passed = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
for (const tc of testCases) {
|
|
||||||
try {
|
|
||||||
// 测试词法分析
|
|
||||||
const tokens = tokenize(tc.formula);
|
|
||||||
console.log(`\n公式:${tc.formula}`);
|
|
||||||
console.log(` 令牌:${tokens.length} 个`);
|
|
||||||
|
|
||||||
// 测试语法分析
|
|
||||||
const pools = parse(tokens);
|
|
||||||
console.log(` 骰池:${pools.length} 个`);
|
|
||||||
|
|
||||||
// 测试掷骰
|
|
||||||
const result = roll(tc.formula);
|
|
||||||
console.log(` 结果:${result.total}`);
|
|
||||||
console.log(` 详情:${result.detail}`);
|
|
||||||
|
|
||||||
// 验证
|
|
||||||
if (pools.length >= tc.expected.hasPools) {
|
|
||||||
console.log(` ✓ 通过`);
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log(` ✗ 失败:期望至少 ${tc.expected.hasPools} 个骰池,实际 ${pools.length} 个`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tc.expected.hasModifier) {
|
|
||||||
const hasMod = pools.some((p) => p.modifier !== undefined);
|
|
||||||
if (hasMod) {
|
|
||||||
console.log(` ✓ 修饰符正确`);
|
|
||||||
} else {
|
|
||||||
console.log(` ✗ 修饰符缺失`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(` ✗ 错误:${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n=== 测试完成:${passed} 通过,${failed} 失败 ===`);
|
|
||||||
return { passed, failed };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在浏览器环境下暴露到全局
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
(window as any).testDiceEngine = testDiceEngine;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export { useCommander } from "./useCommander";
|
|
||||||
export type { UseCommanderReturn } from "./useCommander";
|
|
||||||
export { rollSimple, rollFormula } from "./useDiceRoller";
|
|
||||||
export type { DiceRollerResult } from "./useDiceRoller";
|
|
||||||
export { parseInput, getCompletions, getResultClass } from "./completions";
|
|
||||||
export type { ParsedCommand } from "./completions";
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { createSignal } from "solid-js";
|
|
||||||
import type {
|
|
||||||
CommanderEntry,
|
|
||||||
CompletionItem,
|
|
||||||
TrackerItem,
|
|
||||||
TrackerAttribute,
|
|
||||||
TrackerViewMode,
|
|
||||||
MdCommanderCommandMap,
|
|
||||||
TrackerCommand,
|
|
||||||
} from "../types";
|
|
||||||
import { parseInput, getCompletions } from "./completions";
|
|
||||||
import {
|
|
||||||
addTrackerItem,
|
|
||||||
getTrackerHistory,
|
|
||||||
getTrackerItems,
|
|
||||||
moveTrackerItem,
|
|
||||||
removeTrackerClass,
|
|
||||||
removeTrackerItem,
|
|
||||||
updateTrackerAttribute,
|
|
||||||
updateTrackerClasses,
|
|
||||||
} from "../stores";
|
|
||||||
import { addEntry, getEntries, clearEntries } from "../stores/entriesStore";
|
|
||||||
|
|
||||||
// ==================== Commander Hook ====================
|
|
||||||
|
|
||||||
export interface UseCommanderReturn {
|
|
||||||
inputValue: () => string;
|
|
||||||
entries: () => CommanderEntry[];
|
|
||||||
showCompletions: () => boolean;
|
|
||||||
completions: () => CompletionItem[];
|
|
||||||
selectedCompletion: () => number;
|
|
||||||
isFocused: () => boolean;
|
|
||||||
setInputValue: (v: string) => void;
|
|
||||||
setShowCompletions: (v: boolean) => void;
|
|
||||||
setSelectedCompletion: (v: number | ((prev: number) => number)) => void;
|
|
||||||
setIsFocused: (v: boolean) => void;
|
|
||||||
handleCommand: () => void;
|
|
||||||
updateCompletions: () => void;
|
|
||||||
acceptCompletion: () => void;
|
|
||||||
commands: () => MdCommanderCommandMap;
|
|
||||||
setCommands: (cmds: MdCommanderCommandMap) => void;
|
|
||||||
historyIndex: () => number;
|
|
||||||
setHistoryIndex: (v: number) => void;
|
|
||||||
commandHistory: () => string[];
|
|
||||||
navigateHistory: (direction: "up" | "down") => void;
|
|
||||||
|
|
||||||
// Tracker 相关
|
|
||||||
viewMode: () => TrackerViewMode;
|
|
||||||
setViewMode: (mode: TrackerViewMode) => void;
|
|
||||||
trackerItems: () => TrackerItem[];
|
|
||||||
trackerHistory: () => TrackerCommand[];
|
|
||||||
addTrackerItem: (item: Omit<TrackerItem, "id">) => TrackerItem;
|
|
||||||
removeTrackerItem: (emmet: string) => boolean;
|
|
||||||
removeTrackerItemByIndex: (index: number) => void;
|
|
||||||
updateTrackerAttribute: (emmet: string, attrName: string, attr: TrackerAttribute) => boolean;
|
|
||||||
updateTrackerAttributeByIndex: (index: number, attrName: string, attr: TrackerAttribute) => void;
|
|
||||||
updateTrackerClassesByIndex: (index: number, classes: string[]) => void;
|
|
||||||
moveTrackerItem: (emmet: string, direction: "up" | "down") => boolean;
|
|
||||||
moveTrackerItemByIndex: (index: number, direction: "up" | "down") => void;
|
|
||||||
removeTrackerItemClass: (emmet: string, className: string) => boolean;
|
|
||||||
removeTrackerItemClassByIndex: (index: number, className: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCommander(initialCommands?: MdCommanderCommandMap): UseCommanderReturn {
|
|
||||||
const [inputValue, setInputValue] = createSignal("");
|
|
||||||
const [showCompletions, setShowCompletions] = createSignal(false);
|
|
||||||
const [completions, setCompletions] = createSignal<CompletionItem[]>([]);
|
|
||||||
const [selectedCompletion, setSelectedCompletionState] = createSignal(0);
|
|
||||||
const [isFocused, setIsFocused] = createSignal(false);
|
|
||||||
const [commandHistory, setCommandHistory] = createSignal<string[]>([]);
|
|
||||||
const [historyIndex, setHistoryIndex] = createSignal(-1);
|
|
||||||
const [viewMode, setViewMode] = createSignal<TrackerViewMode>("history");
|
|
||||||
const [commands, setCommands] = createSignal<MdCommanderCommandMap>(initialCommands || {});
|
|
||||||
|
|
||||||
// 从 store 获取 entries
|
|
||||||
const entries = getEntries;
|
|
||||||
|
|
||||||
// ==================== 命令执行 ====================
|
|
||||||
|
|
||||||
const handleCommand = () => {
|
|
||||||
const input = inputValue().trim();
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
const cmds = commands();
|
|
||||||
const parsed = parseInput(input, cmds);
|
|
||||||
const commandName = parsed.command;
|
|
||||||
const cmd = cmds[commandName!];
|
|
||||||
|
|
||||||
let result: { message: string; type?: "success" | "error" | "warning" | "info"; isHtml?: boolean };
|
|
||||||
|
|
||||||
if (!cmd) {
|
|
||||||
result = { message: `未知命令:${commandName}`, type: "error" };
|
|
||||||
} else if (cmd.handler) {
|
|
||||||
try {
|
|
||||||
result = cmd.handler({ params: parsed.params, options: parsed.options }, cmds);
|
|
||||||
} catch (e) {
|
|
||||||
result = {
|
|
||||||
message: `执行错误:${e instanceof Error ? e.message : String(e)}`,
|
|
||||||
type: "error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result = { message: `命令 ${commandName} 已执行(无处理器)`, type: "info" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEntry: CommanderEntry = {
|
|
||||||
id: Date.now().toString() + Math.random().toString(36).slice(2),
|
|
||||||
command: input,
|
|
||||||
args: parsed.options,
|
|
||||||
result,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 使用 store 添加记录
|
|
||||||
addEntry(newEntry);
|
|
||||||
setCommandHistory((prev) => [...prev, input]);
|
|
||||||
setHistoryIndex(-1);
|
|
||||||
setInputValue("");
|
|
||||||
setShowCompletions(false);
|
|
||||||
|
|
||||||
if (commandName === "clear") {
|
|
||||||
clearEntries();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== 自动补全 ====================
|
|
||||||
|
|
||||||
const updateCompletions = () => {
|
|
||||||
const comps = getCompletions(inputValue(), commands());
|
|
||||||
setCompletions(comps);
|
|
||||||
setShowCompletions(comps.length > 0 && isFocused());
|
|
||||||
setSelectedCompletionState(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setSelectedCompletion = (v: number | ((prev: number) => number)) => {
|
|
||||||
const maxIdx = completions().length - 1;
|
|
||||||
if (typeof v === "function") {
|
|
||||||
setSelectedCompletionState((prev) => {
|
|
||||||
const next = v(prev);
|
|
||||||
return Math.max(0, Math.min(next, maxIdx));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setSelectedCompletionState(Math.max(0, Math.min(v, maxIdx)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const acceptCompletion = () => {
|
|
||||||
const idx = selectedCompletion();
|
|
||||||
const comps = completions();
|
|
||||||
const comp = comps[Math.min(idx, comps.length - 1)];
|
|
||||||
if (!comp) return;
|
|
||||||
|
|
||||||
const input = inputValue();
|
|
||||||
const parsed = parseInput(input, commands());
|
|
||||||
let newValue: string;
|
|
||||||
|
|
||||||
if (comp.type === "command") {
|
|
||||||
newValue = comp.insertText + " ";
|
|
||||||
} else if (comp.type === "option") {
|
|
||||||
const existingOptions = Object.entries(parsed.options)
|
|
||||||
.map(([k, v]) => `--${k}=${v}`)
|
|
||||||
.join(" ");
|
|
||||||
newValue = `${parsed.command || ""} ${existingOptions}${existingOptions ? " " : ""}${comp.insertText}`;
|
|
||||||
} else if (comp.type === "value") {
|
|
||||||
const cmd = parsed.command ? commands()[parsed.command] : null;
|
|
||||||
const paramDefs = cmd?.parameters || [];
|
|
||||||
const usedParams = Object.keys(parsed.params);
|
|
||||||
const isTypingLastParam = paramDefs.length === usedParams.length && !input.endsWith(" ");
|
|
||||||
|
|
||||||
if (isTypingLastParam) {
|
|
||||||
const parts = input.trim().split(/\s+/);
|
|
||||||
parts.pop();
|
|
||||||
newValue = `${parsed.command || ""}${parts.length > 1 ? " " + parts.slice(1).join(" ") : ""} ${comp.insertText}`;
|
|
||||||
} else if (paramDefs.length > usedParams.length) {
|
|
||||||
const existingParams = Object.values(parsed.params).join(" ");
|
|
||||||
newValue = `${parsed.command || ""} ${existingParams}${existingParams ? " " : ""}${comp.insertText}`;
|
|
||||||
} else {
|
|
||||||
newValue = input;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newValue = input;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInputValue(newValue.trim());
|
|
||||||
setShowCompletions(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== 命令历史 ====================
|
|
||||||
|
|
||||||
const navigateHistory = (direction: "up" | "down") => {
|
|
||||||
const history = commandHistory();
|
|
||||||
if (history.length === 0) return;
|
|
||||||
|
|
||||||
let newIndex = historyIndex();
|
|
||||||
if (direction === "up") {
|
|
||||||
if (newIndex < history.length - 1) newIndex++;
|
|
||||||
} else {
|
|
||||||
if (newIndex > 0) {
|
|
||||||
newIndex--;
|
|
||||||
} else {
|
|
||||||
setInputValue("");
|
|
||||||
setHistoryIndex(-1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setHistoryIndex(newIndex);
|
|
||||||
setInputValue(history[history.length - 1 - newIndex]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Tracker 操作 ====================
|
|
||||||
|
|
||||||
const getEmmetFromIndex = (index: number): string | null => {
|
|
||||||
const items = getTrackerItems();
|
|
||||||
if (index < 0 || index >= items.length) return null;
|
|
||||||
const item = items[index];
|
|
||||||
return `${item.tag}${item.id ? "#" + item.id : ""}${item.classes.length > 0 ? "." + item.classes.join(".") : ""}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTrackerItemByIndex = (index: number) => {
|
|
||||||
const emmet = getEmmetFromIndex(index);
|
|
||||||
if (emmet) removeTrackerItem(emmet);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTrackerAttributeByIndex = (index: number, attrName: string, attr: TrackerAttribute) => {
|
|
||||||
const emmet = getEmmetFromIndex(index);
|
|
||||||
if (emmet) updateTrackerAttribute(emmet, attrName, attr);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTrackerClassesByIndex = (index: number, classes: string[]) => {
|
|
||||||
const emmet = getEmmetFromIndex(index);
|
|
||||||
if (emmet) updateTrackerClasses(emmet, classes);
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveTrackerItemByIndex = (index: number, direction: "up" | "down") => {
|
|
||||||
const emmet = getEmmetFromIndex(index);
|
|
||||||
if (emmet) moveTrackerItem(emmet, direction);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTrackerItemClassByIndex = (index: number, className: string) => {
|
|
||||||
const emmet = getEmmetFromIndex(index);
|
|
||||||
if (emmet) removeTrackerClass(emmet, className);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
inputValue,
|
|
||||||
entries,
|
|
||||||
showCompletions,
|
|
||||||
completions,
|
|
||||||
selectedCompletion,
|
|
||||||
isFocused,
|
|
||||||
setInputValue,
|
|
||||||
setShowCompletions,
|
|
||||||
setSelectedCompletion,
|
|
||||||
setIsFocused,
|
|
||||||
handleCommand,
|
|
||||||
updateCompletions,
|
|
||||||
acceptCompletion,
|
|
||||||
commands,
|
|
||||||
setCommands,
|
|
||||||
historyIndex,
|
|
||||||
setHistoryIndex,
|
|
||||||
commandHistory,
|
|
||||||
navigateHistory,
|
|
||||||
viewMode,
|
|
||||||
setViewMode,
|
|
||||||
trackerItems: getTrackerItems,
|
|
||||||
trackerHistory: getTrackerHistory,
|
|
||||||
addTrackerItem,
|
|
||||||
removeTrackerItem,
|
|
||||||
removeTrackerItemByIndex,
|
|
||||||
updateTrackerAttribute,
|
|
||||||
updateTrackerAttributeByIndex,
|
|
||||||
updateTrackerClassesByIndex,
|
|
||||||
moveTrackerItem,
|
|
||||||
moveTrackerItemByIndex,
|
|
||||||
removeTrackerItemClass: removeTrackerClass,
|
|
||||||
removeTrackerItemClassByIndex,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { customElement, noShadowDOM } from "solid-element";
|
|
||||||
import { onMount, onCleanup, createResource } from "solid-js";
|
|
||||||
import { useCommander } from "./hooks";
|
|
||||||
import { CommanderInput } from "./CommanderInput";
|
|
||||||
import { CommanderEntries } from "./CommanderEntries";
|
|
||||||
import { TrackerView } from "./TrackerView";
|
|
||||||
import { TabBar } from "./TabBar";
|
|
||||||
import type { MdCommanderProps } from "./types";
|
|
||||||
import { loadElementSrc } from "../utils/path";
|
|
||||||
import { initializeCommands, loadCommandTemplatesFromCSV, getCommands, getCommandsLoading, getCommandsError } from "./stores";
|
|
||||||
|
|
||||||
customElement<MdCommanderProps>(
|
|
||||||
"md-commander",
|
|
||||||
{ placeholder: "", class: "", height: "", commandTemplates: "" },
|
|
||||||
(props, { element }) => {
|
|
||||||
noShadowDOM();
|
|
||||||
const { articlePath, rawSrc } = loadElementSrc(element);
|
|
||||||
|
|
||||||
// 初始化命令
|
|
||||||
const commands = initializeCommands(props.commands);
|
|
||||||
const commander = useCommander(commands);
|
|
||||||
|
|
||||||
// 使用 createResource 加载 CSV 模板
|
|
||||||
const [templateData] = createResource(
|
|
||||||
() => (rawSrc ? { path: rawSrc, articlePath } : null),
|
|
||||||
async (paths) => {
|
|
||||||
await loadCommandTemplatesFromCSV(paths.path, paths.articlePath);
|
|
||||||
return getCommands();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当模板加载完成后更新 commander 中的命令
|
|
||||||
createResource(
|
|
||||||
() => templateData(),
|
|
||||||
(loadedCommands) => {
|
|
||||||
if (loadedCommands) {
|
|
||||||
commander.setCommands(loadedCommands);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (commander.showCompletions() && commander.completions().length > 0) {
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.setSelectedCompletion(
|
|
||||||
(prev) => (prev + 1) % commander.completions().length,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.setSelectedCompletion(
|
|
||||||
(prev) => (prev - 1 + commander.completions().length) % commander.completions().length,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Tab") {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.acceptCompletion();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
commander.setShowCompletions(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === "ArrowUp" && !commander.showCompletions()) {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.navigateHistory("up");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowDown" && !commander.showCompletions()) {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.navigateHistory("down");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.handleCommand();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const heightStyle = () => props.height || "400px";
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (!element?.contains(e.target as Node)) {
|
|
||||||
commander.setShowCompletions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("click", handleClickOutside);
|
|
||||||
onCleanup(() => document.removeEventListener("click", handleClickOutside));
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={`w-full flex flex-col border border-gray-300 rounded-lg overflow-hidden mb-4 ${props.class || ""}`}
|
|
||||||
style={{ height: heightStyle() }}
|
|
||||||
>
|
|
||||||
<TabBar mode={commander.viewMode} onModeChange={commander.setViewMode} />
|
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col lg:flex-row">
|
|
||||||
{/* 历史视图 */}
|
|
||||||
<div class={`flex-1 flex-col min-h-0 lg:flex lg:w-1/2 lg:border-r lg:border-gray-300 ${commander.viewMode() !== "history" ? "hidden" : ""}`}>
|
|
||||||
<CommanderEntries
|
|
||||||
entries={commander.entries}
|
|
||||||
loading={getCommandsLoading()}
|
|
||||||
error={getCommandsError()}
|
|
||||||
onCommandClick={(cmd) => commander.setInputValue(cmd)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 追踪视图 */}
|
|
||||||
<div class={`flex-1 flex-col min-h-0 lg:flex lg:w-1/2 lg:border-r lg:border-gray-300 ${commander.viewMode() !== "tracker" ? "hidden" : ""}`}>
|
|
||||||
<TrackerView
|
|
||||||
items={commander.trackerItems}
|
|
||||||
onEditAttribute={(index, attrName, attr) =>
|
|
||||||
commander.updateTrackerAttributeByIndex(index, attrName, attr)
|
|
||||||
}
|
|
||||||
onClassesChange={(index, classes) =>
|
|
||||||
commander.updateTrackerClassesByIndex(index, classes)
|
|
||||||
}
|
|
||||||
onRemoveClass={(index, className) =>
|
|
||||||
commander.removeTrackerItemClassByIndex(index, className)
|
|
||||||
}
|
|
||||||
onMoveUp={(index) => commander.moveTrackerItemByIndex(index, "up")}
|
|
||||||
onMoveDown={(index) => commander.moveTrackerItemByIndex(index, "down")}
|
|
||||||
onRemove={(index) => commander.removeTrackerItemByIndex(index)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative border-t border-gray-300">
|
|
||||||
<CommanderInput
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
inputValue={commander.inputValue}
|
|
||||||
onInput={(e) => {
|
|
||||||
commander.setInputValue((e.target as HTMLInputElement).value);
|
|
||||||
commander.updateCompletions();
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onFocus={() => {
|
|
||||||
commander.setIsFocused(true);
|
|
||||||
commander.updateCompletions();
|
|
||||||
}}
|
|
||||||
onBlur={() => commander.setIsFocused(false)}
|
|
||||||
onSubmit={commander.handleCommand}
|
|
||||||
showCompletions={commander.showCompletions}
|
|
||||||
completions={commander.completions}
|
|
||||||
selectedCompletion={commander.selectedCompletion}
|
|
||||||
onSelectCompletion={commander.setSelectedCompletion}
|
|
||||||
onAcceptCompletion={commander.acceptCompletion}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import { createStore } from "solid-js/store";
|
|
||||||
import type { MdCommanderCommand, MdCommanderCommandMap } from "../types";
|
|
||||||
import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands";
|
|
||||||
import {resolvePath} from "../../utils/path";
|
|
||||||
import {loadCSVFromPath} from "../../utils/csv-loader";
|
|
||||||
|
|
||||||
const defaultCommands: MdCommanderCommandMap = {
|
|
||||||
help: setupHelpCommand({}),
|
|
||||||
clear: clearCommand,
|
|
||||||
roll: rollCommand,
|
|
||||||
track: trackCommand,
|
|
||||||
untrack: untrackCommand,
|
|
||||||
list: listTrackCommand,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CommandsStoreState {
|
|
||||||
commands: MdCommanderCommandMap;
|
|
||||||
initialized: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [commandsStore, setCommandsStore] = createStore<CommandsStoreState>({
|
|
||||||
commands: { ...defaultCommands },
|
|
||||||
initialized: false,
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化命令(包括 help 命令的动态更新)
|
|
||||||
*/
|
|
||||||
export function initializeCommands(customCommands?: MdCommanderCommandMap): MdCommanderCommandMap {
|
|
||||||
const commands = { ...defaultCommands, ...customCommands };
|
|
||||||
// 更新 help 命令的命令列表
|
|
||||||
commands.help = setupHelpCommand(commands);
|
|
||||||
return commands;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册命令到 store
|
|
||||||
*/
|
|
||||||
export function registerCommands(customCommands?: MdCommanderCommandMap): void {
|
|
||||||
const commands = initializeCommands(customCommands);
|
|
||||||
setCommandsStore({ commands, initialized: true, loading: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 store 获取命令
|
|
||||||
*/
|
|
||||||
export function getCommands(): MdCommanderCommandMap {
|
|
||||||
return commandsStore.commands;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查命令是否已初始化
|
|
||||||
*/
|
|
||||||
export function isCommandsInitialized(): boolean {
|
|
||||||
return commandsStore.initialized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取单个命令
|
|
||||||
*/
|
|
||||||
export function getCommand(name: string): MdCommanderCommand | undefined {
|
|
||||||
return commandsStore.commands[name];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取加载状态
|
|
||||||
*/
|
|
||||||
export function getCommandsLoading(): boolean {
|
|
||||||
return commandsStore.loading;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取错误信息
|
|
||||||
*/
|
|
||||||
export function getCommandsError(): string | undefined {
|
|
||||||
return commandsStore.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置加载状态
|
|
||||||
*/
|
|
||||||
export function setCommandsLoading(loading: boolean): void {
|
|
||||||
setCommandsStore("loading", loading);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置错误信息
|
|
||||||
*/
|
|
||||||
export function setCommandsError(error?: string): void {
|
|
||||||
setCommandsStore("error", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新命令
|
|
||||||
*/
|
|
||||||
export function updateCommands(updater: (prev: MdCommanderCommandMap) => MdCommanderCommandMap): void {
|
|
||||||
setCommandsStore("commands", (prev) => updater(prev));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 CSV 文件加载命令模板并更新命令定义
|
|
||||||
*/
|
|
||||||
export async function loadCommandTemplatesFromCSV(
|
|
||||||
path: string,
|
|
||||||
articlePath: string
|
|
||||||
): Promise<void> {
|
|
||||||
setCommandsLoading(true);
|
|
||||||
setCommandsError(undefined);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const csv = await loadCSVFromPath<CommandTemplateRow>(resolvePath(articlePath, path));
|
|
||||||
|
|
||||||
// 按命令分组模板
|
|
||||||
const templatesByCommand = new Map<string, CommandTemplateRow[]>();
|
|
||||||
for (const row of csv) {
|
|
||||||
if (!row.command || !row.label || !row.insertedText) continue;
|
|
||||||
|
|
||||||
if (!templatesByCommand.has(row.command)) {
|
|
||||||
templatesByCommand.set(row.command, []);
|
|
||||||
}
|
|
||||||
templatesByCommand.get(row.command)!.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为每个命令添加模板
|
|
||||||
updateCommands((prev) => {
|
|
||||||
const updated = { ...prev };
|
|
||||||
for (const [commandName, rows] of templatesByCommand.entries()) {
|
|
||||||
const cmd = updated[commandName];
|
|
||||||
if (!cmd || !cmd.parameters) continue;
|
|
||||||
|
|
||||||
const templates = rows.map((row) => ({
|
|
||||||
label: row.label,
|
|
||||||
description: row.description || "",
|
|
||||||
insertText: row.insertedText,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 为每个参数添加模板
|
|
||||||
updated[commandName] = {
|
|
||||||
...cmd,
|
|
||||||
parameters: cmd.parameters.map((param) => ({
|
|
||||||
...param,
|
|
||||||
templates: param.templates ? [...param.templates, ...templates] : templates,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
setCommandsStore("initialized", true);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
setCommandsError(`加载命令模板失败:${errorMessage}`);
|
|
||||||
console.warn(`Error loading command templates from ${path}:`, error);
|
|
||||||
} finally {
|
|
||||||
setCommandsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandTemplateRow {
|
|
||||||
command: string;
|
|
||||||
parameter: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
insertedText: string;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { createStore } from "solid-js/store";
|
|
||||||
import type { CommanderEntry } from "../types";
|
|
||||||
|
|
||||||
export interface EntriesStore {
|
|
||||||
entries: CommanderEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const [entriesStore, setEntriesStore] = createStore<EntriesStore>({
|
|
||||||
entries: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加命令执行记录
|
|
||||||
*/
|
|
||||||
export function addEntry(entry: CommanderEntry): void {
|
|
||||||
setEntriesStore("entries", (prev) => [...prev, entry]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空所有记录
|
|
||||||
*/
|
|
||||||
export function clearEntries(): void {
|
|
||||||
setEntriesStore("entries", []);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有记录
|
|
||||||
*/
|
|
||||||
export function getEntries(): CommanderEntry[] {
|
|
||||||
return entriesStore.entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量设置记录
|
|
||||||
*/
|
|
||||||
export function setEntries(entries: CommanderEntry[]): void {
|
|
||||||
setEntriesStore("entries", entries);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
export {
|
|
||||||
addTrackerItem,
|
|
||||||
removeTrackerItem,
|
|
||||||
updateTrackerAttribute,
|
|
||||||
updateTrackerClasses,
|
|
||||||
moveTrackerItem,
|
|
||||||
removeTrackerClass,
|
|
||||||
getTrackerItems,
|
|
||||||
getTrackerHistory,
|
|
||||||
clearTrackerHistory,
|
|
||||||
findTrackerIndex,
|
|
||||||
findTrackerItem,
|
|
||||||
} from "./trackerStore";
|
|
||||||
export type { TrackerStore } from "./trackerStore";
|
|
||||||
|
|
||||||
export {
|
|
||||||
initializeCommands,
|
|
||||||
registerCommands,
|
|
||||||
getCommands,
|
|
||||||
isCommandsInitialized,
|
|
||||||
getCommand,
|
|
||||||
loadCommandTemplatesFromCSV,
|
|
||||||
getCommandsLoading,
|
|
||||||
getCommandsError,
|
|
||||||
setCommandsLoading,
|
|
||||||
setCommandsError,
|
|
||||||
updateCommands,
|
|
||||||
} from "./commandsStore";
|
|
||||||
export type { CommandsStoreState } from "./commandsStore";
|
|
||||||
|
|
||||||
export {
|
|
||||||
addEntry,
|
|
||||||
clearEntries,
|
|
||||||
getEntries,
|
|
||||||
setEntries,
|
|
||||||
} from "./entriesStore";
|
|
||||||
export type { EntriesStore } from "./entriesStore";
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import { createStore } from "solid-js/store";
|
|
||||||
import type { TrackerItem, TrackerAttribute, TrackerCommand } from "../types";
|
|
||||||
import {parseEmmet} from "../utils";
|
|
||||||
|
|
||||||
export interface TrackerStore {
|
|
||||||
items: TrackerItem[];
|
|
||||||
history: TrackerCommand[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const [tracker, setTracker] = createStore<TrackerStore>({
|
|
||||||
items: [],
|
|
||||||
history: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据 Emmet 语法查找匹配的项目
|
|
||||||
* 支持:
|
|
||||||
* - #id - 匹配 id
|
|
||||||
* - tag - 匹配 tag
|
|
||||||
* - tag.class - 匹配 tag 和 class
|
|
||||||
* - .class - 匹配 class
|
|
||||||
* - [attr=value] - 匹配属性
|
|
||||||
*/
|
|
||||||
export function findTrackerIndex(emmet: string): number {
|
|
||||||
const {tag, id, classes, attributes} = parseEmmet(emmet);
|
|
||||||
|
|
||||||
return tracker.items.findIndex(item => {
|
|
||||||
// 匹配 tag
|
|
||||||
if (tag && item.tag !== tag) return false;
|
|
||||||
// 匹配 id
|
|
||||||
if (id && item.id !== id) return false;
|
|
||||||
// 匹配所有 classes
|
|
||||||
if (classes.length > 0) {
|
|
||||||
for (const cls of classes) {
|
|
||||||
if (!item.classes.includes(cls)) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 匹配属性
|
|
||||||
for(const attrName in attributes){
|
|
||||||
const attr = attributes[attrName];
|
|
||||||
if (item.attributes[attrName] !== attr) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findTrackerItem(emmet: string): TrackerItem | undefined {
|
|
||||||
const index = findTrackerIndex(emmet);
|
|
||||||
return index >= 0 ? tracker.items[index] : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addTrackerItem(item: Omit<TrackerItem, "uuid">): TrackerItem {
|
|
||||||
const newItem: TrackerItem = {
|
|
||||||
...item,
|
|
||||||
};
|
|
||||||
|
|
||||||
setTracker("items", (prev) => [...prev, newItem]);
|
|
||||||
setTracker("history", (prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
type: "add",
|
|
||||||
itemId: newItem.id,
|
|
||||||
data: newItem,
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return newItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeTrackerItem(emmet: string): boolean {
|
|
||||||
const index = findTrackerIndex(emmet);
|
|
||||||
if (index === -1) return false;
|
|
||||||
|
|
||||||
const item = tracker.items[index];
|
|
||||||
|
|
||||||
setTracker("items", (prev) => prev.filter((_, i) => i !== index));
|
|
||||||
setTracker("history", (prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
type: "remove",
|
|
||||||
itemId: `${item.tag}${item.id ? '#' + item.id : ''}`,
|
|
||||||
data: item,
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateTrackerAttribute(
|
|
||||||
emmet: string,
|
|
||||||
attrName: string,
|
|
||||||
attr: TrackerAttribute
|
|
||||||
): boolean {
|
|
||||||
const index = findTrackerIndex(emmet);
|
|
||||||
if (index === -1) return false;
|
|
||||||
|
|
||||||
const item = tracker.items[index];
|
|
||||||
setTracker("items", (prev) =>
|
|
||||||
prev.map((i, idx) =>
|
|
||||||
idx === index
|
|
||||||
? { ...i, attributes: { ...i.attributes, [attrName]: attr } }
|
|
||||||
: i
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setTracker("history", (prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
type: "update",
|
|
||||||
itemId: `${item.tag}${item.id ? '#' + item.id : ''}`,
|
|
||||||
attributeUpdates: { [attrName]: attr },
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveTrackerItem(emmet: string, direction: "up" | "down"): boolean {
|
|
||||||
const index = findTrackerIndex(emmet);
|
|
||||||
if (index === -1) return false;
|
|
||||||
|
|
||||||
const newIndex = direction === "up" ? index - 1 : index + 1;
|
|
||||||
if (newIndex < 0 || newIndex >= tracker.items.length) return false;
|
|
||||||
|
|
||||||
const newItems = [...tracker.items];
|
|
||||||
[newItems[index], newItems[newIndex]] = [newItems[newIndex], newItems[index]];
|
|
||||||
|
|
||||||
setTracker("items", newItems);
|
|
||||||
setTracker("history", (prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
type: "reorder",
|
|
||||||
data: newItems,
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeTrackerClass(emmet: string, className: string): boolean {
|
|
||||||
const index = findTrackerIndex(emmet);
|
|
||||||
if (index === -1) return false;
|
|
||||||
|
|
||||||
const item = tracker.items[index];
|
|
||||||
setTracker("items", (prev) =>
|
|
||||||
prev.map((i, idx) =>
|
|
||||||
idx === index
|
|
||||||
? { ...i, classes: i.classes.filter((c) => c !== className) }
|
|
||||||
: i
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setTracker("history", (prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
type: "update",
|
|
||||||
itemId: `${item.tag}${item.id ? '#' + item.id : ''}`,
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateTrackerClasses(emmet: string, classes: string[]): boolean {
|
|
||||||
const index = findTrackerIndex(emmet);
|
|
||||||
if (index === -1) return false;
|
|
||||||
|
|
||||||
const item = tracker.items[index];
|
|
||||||
setTracker("items", (prev) =>
|
|
||||||
prev.map((i, idx) =>
|
|
||||||
idx === index
|
|
||||||
? { ...i, classes: [...classes] }
|
|
||||||
: i
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setTracker("history", (prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
type: "update",
|
|
||||||
itemId: `${item.tag}${item.id ? '#' + item.id : ''}`,
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTrackerItems(): TrackerItem[] {
|
|
||||||
return tracker.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTrackerHistory(): TrackerCommand[] {
|
|
||||||
return tracker.history;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearTrackerHistory() {
|
|
||||||
setTracker("history", []);
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import type { Component } from "solid-js";
|
|
||||||
|
|
||||||
// ==================== 组件 Props ====================
|
|
||||||
|
|
||||||
export interface MdCommanderProps {
|
|
||||||
placeholder?: string;
|
|
||||||
class?: string;
|
|
||||||
height?: string;
|
|
||||||
commands?: Record<string, MdCommanderCommand>;
|
|
||||||
commandTemplates?: string | string[]; // CSV 文件路径或路径数组
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 命令定义 ====================
|
|
||||||
|
|
||||||
export interface MdCommanderCommandMap {
|
|
||||||
[key: string]: MdCommanderCommand;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MdCommanderCommand {
|
|
||||||
command: string;
|
|
||||||
description?: string;
|
|
||||||
parameters?: MdCommanderParameter[];
|
|
||||||
options?: Record<string, MdCommanderOption>;
|
|
||||||
handler?: (args: {
|
|
||||||
params: Record<string, string>;
|
|
||||||
options: Record<string, string>;
|
|
||||||
}, commands?: MdCommanderCommandMap) => {
|
|
||||||
message: string;
|
|
||||||
type?: "success" | "error" | "info" | "warning";
|
|
||||||
isHtml?: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MdCommanderParameter {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
type: MdCommanderOptionType;
|
|
||||||
required?: boolean;
|
|
||||||
default?: string;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
values?: string[];
|
|
||||||
templates?: MdCommanderTemplate[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MdCommanderTemplate {
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
insertText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MdCommanderOptionType =
|
|
||||||
| "string"
|
|
||||||
| "number"
|
|
||||||
| "enum"
|
|
||||||
| "tag"
|
|
||||||
| "boolean";
|
|
||||||
|
|
||||||
export interface MdCommanderOption {
|
|
||||||
option: string;
|
|
||||||
description?: string;
|
|
||||||
type: MdCommanderOptionType;
|
|
||||||
required?: boolean;
|
|
||||||
default?: any;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
values?: string[];
|
|
||||||
EntryComponent?: Component<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 命令执行记录 ====================
|
|
||||||
|
|
||||||
export interface CommanderEntry {
|
|
||||||
id: string;
|
|
||||||
command: string;
|
|
||||||
args: Record<string, any>;
|
|
||||||
result: {
|
|
||||||
message: string;
|
|
||||||
type?: "success" | "error" | "warning" | "info";
|
|
||||||
isHtml?: boolean;
|
|
||||||
};
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompletionItem {
|
|
||||||
label: string;
|
|
||||||
type: "command" | "option" | "value";
|
|
||||||
description?: string;
|
|
||||||
insertText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Tracker ====================
|
|
||||||
|
|
||||||
export type TrackerViewMode = "history" | "tracker";
|
|
||||||
|
|
||||||
export type TrackerAttributeType = "progress" | "count" | "string";
|
|
||||||
|
|
||||||
export interface TrackerAttribute {
|
|
||||||
name: string;
|
|
||||||
type: TrackerAttributeType;
|
|
||||||
value: string | number | { x: number; y: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrackerItem {
|
|
||||||
tag: string;
|
|
||||||
id?: string;
|
|
||||||
classes: string[];
|
|
||||||
attributes: Record<string, TrackerAttribute>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrackerCommand {
|
|
||||||
type: "add" | "remove" | "update" | "reorder";
|
|
||||||
itemId?: string;
|
|
||||||
data?: Partial<TrackerItem> | TrackerItem[];
|
|
||||||
attributeUpdates?: Record<string, TrackerAttribute>;
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import type { TrackerAttribute } from "../types";
|
|
||||||
|
|
||||||
export interface ParsedEmmet {
|
|
||||||
tag: string;
|
|
||||||
id?: string;
|
|
||||||
classes: string[];
|
|
||||||
attributes: Record<string, TrackerAttribute>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 Emmet 风格的 tracker 语法
|
|
||||||
* 格式:tag#id.class1.class2[attr1=value1 attr2=value2]
|
|
||||||
* 示例:npc#john.dwarf.warrior[hp=4/4 ac=15 name="John the Dwarf"]
|
|
||||||
*/
|
|
||||||
export function parseEmmet(input: string): ParsedEmmet {
|
|
||||||
const result: ParsedEmmet = {
|
|
||||||
tag: "",
|
|
||||||
classes: [],
|
|
||||||
attributes: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!input) return result;
|
|
||||||
|
|
||||||
// 匹配属性部分 [...]
|
|
||||||
const attrMatch = input.match(/\[(.+)\]$/);
|
|
||||||
let attrString: string | undefined;
|
|
||||||
let mainPart: string;
|
|
||||||
|
|
||||||
if (attrMatch) {
|
|
||||||
attrString = attrMatch[1];
|
|
||||||
mainPart = input.slice(0, attrMatch.index);
|
|
||||||
} else {
|
|
||||||
mainPart = input;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 tag、id 和 classes
|
|
||||||
// 格式:tag#id.class1.class2.class3
|
|
||||||
const tagClassMatch = mainPart.match(/^([^#.\s]+)?(?:#([^.\s]+))?(?:\.([^.]+(?:\.[^.]+)*))?/);
|
|
||||||
|
|
||||||
if (tagClassMatch) {
|
|
||||||
if (tagClassMatch[1]) {
|
|
||||||
result.tag = tagClassMatch[1];
|
|
||||||
}
|
|
||||||
if (tagClassMatch[2]) {
|
|
||||||
// # 后面的是 ID,不是 class
|
|
||||||
result.id = tagClassMatch[2];
|
|
||||||
}
|
|
||||||
if (tagClassMatch[3]) {
|
|
||||||
result.classes.push(...tagClassMatch[3].split("."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析属性
|
|
||||||
if (attrString) {
|
|
||||||
result.attributes = parseAttributes(attrString);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析属性字符串
|
|
||||||
* 支持格式:
|
|
||||||
* - hp=4/4 (progress 类型,自动检测)
|
|
||||||
* - count=5 (count 类型,纯数字)
|
|
||||||
* - name="John" (string 类型,带引号)
|
|
||||||
* - name=John (string 类型,不带引号)
|
|
||||||
*/
|
|
||||||
function parseAttributes(attrString: string): Record<string, TrackerAttribute> {
|
|
||||||
const attributes: Record<string, TrackerAttribute> = {};
|
|
||||||
|
|
||||||
// 匹配键值对:使用,分隔
|
|
||||||
const regex = /(\w+)=(?:"([^"]*)"|'([^']*)'|([^,]+))/g;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((match = regex.exec(attrString)) !== null) {
|
|
||||||
const key = match[1];
|
|
||||||
// 匹配的值可能是第 2、3 或 4 组(取决于引号类型)
|
|
||||||
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
|
||||||
|
|
||||||
// 自动检测类型
|
|
||||||
let attr: TrackerAttribute;
|
|
||||||
|
|
||||||
// 检查是否是 progress 格式 (x/y)
|
|
||||||
const progressMatch = value.match(/^(\d+)\/(\d+)$/);
|
|
||||||
if (progressMatch) {
|
|
||||||
attr = {
|
|
||||||
name: key,
|
|
||||||
type: "progress",
|
|
||||||
value: { x: parseInt(progressMatch[1]), y: parseInt(progressMatch[2]) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// 检查是否是纯数字(count 类型)
|
|
||||||
else if (/^\d+$/.test(value)) {
|
|
||||||
attr = {
|
|
||||||
name: key,
|
|
||||||
type: "count",
|
|
||||||
value: parseInt(value),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// 默认 string 类型
|
|
||||||
else {
|
|
||||||
attr = {
|
|
||||||
name: key,
|
|
||||||
type: "string",
|
|
||||||
value: value.replace(/^["']|["']$/g, ""), // 移除可能的引号
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
attributes[key] = attr;
|
|
||||||
}
|
|
||||||
|
|
||||||
return attributes;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { parseEmmet } from './emmetParser';
|
|
||||||
export type { ParsedEmmet } from './emmetParser';
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { customElement, noShadowDOM } from "solid-element";
|
import { customElement, noShadowDOM } from "solid-element";
|
||||||
import { createSignal, onMount } from "solid-js";
|
import { createSignal, onMount } from "solid-js";
|
||||||
import { rollFormula } from "./md-commander/hooks";
|
import { rollFormula } from "./dice";
|
||||||
|
|
||||||
export interface DiceProps {
|
export interface DiceProps {
|
||||||
key?: string;
|
key?: string;
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export interface JournalStreamState {
|
|||||||
brokerUrl: string | null;
|
brokerUrl: string | null;
|
||||||
players: Record<string, { role: string }>;
|
players: Record<string, { role: string }>;
|
||||||
variables: Record<string, string>;
|
variables: Record<string, string>;
|
||||||
|
/** Raw base values (before tag modifiers), keyed the same as `variables`. */
|
||||||
|
variableBases: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionMeta {
|
export interface SessionMeta {
|
||||||
@@ -98,6 +100,7 @@ const [state, setState] = createStore<JournalStreamState>({
|
|||||||
brokerUrl: persisted.brokerUrl,
|
brokerUrl: persisted.brokerUrl,
|
||||||
players: {},
|
players: {},
|
||||||
variables: {},
|
variables: {},
|
||||||
|
variableBases: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
|
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
|
||||||
@@ -128,11 +131,33 @@ function replayReducers(): void {
|
|||||||
// Reset derived state
|
// Reset derived state
|
||||||
setState("revealedPaths", {});
|
setState("revealedPaths", {});
|
||||||
setState("variables", {});
|
setState("variables", {});
|
||||||
|
setState("variableBases", {});
|
||||||
for (const msg of state.messages) {
|
for (const msg of state.messages) {
|
||||||
runReducer(msg);
|
runReducer(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hydration signal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let _hydrated = false;
|
||||||
|
let _hydrationListener: (() => void) | null = null;
|
||||||
|
|
||||||
|
/** True once the worker has delivered a full state snapshot. */
|
||||||
|
export function hasHydrated(): boolean {
|
||||||
|
return _hydrated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback invoked after each full state snapshot has been applied
|
||||||
|
* (reducers replayed and reactivity rebuilt). Used to seed declared variables
|
||||||
|
* once the hydrated store is available.
|
||||||
|
*/
|
||||||
|
export function setHydrationListener(cb: (() => void) | null): void {
|
||||||
|
_hydrationListener = cb;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Worker patch handler
|
// Worker patch handler
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -165,7 +190,9 @@ function handlePatch(patch: WorkerPatch): void {
|
|||||||
replayReducers();
|
replayReducers();
|
||||||
// Rebuild local reactivity engine state so tag activations from
|
// Rebuild local reactivity engine state so tag activations from
|
||||||
// hydrated variables are tracked for subsequent cascade computations.
|
// hydrated variables are tracked for subsequent cascade computations.
|
||||||
rebuildReactivityFromStore(state.variables);
|
rebuildReactivityFromStore(state.variables, state.variableBases);
|
||||||
|
_hydrated = true;
|
||||||
|
_hydrationListener?.();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { parseSparkTableCsv, rollSparkTable } from "./spark-table";
|
||||||
|
|
||||||
|
const CSV = `2d6,encounter
|
||||||
|
2,Tomato
|
||||||
|
3,Onion
|
||||||
|
4,Cabbage
|
||||||
|
5,Carrot
|
||||||
|
6,Potato
|
||||||
|
7,Leek
|
||||||
|
8,Pumpkin
|
||||||
|
9,Mushroom
|
||||||
|
10,Radish
|
||||||
|
11,Eggplant
|
||||||
|
12,Pepper`;
|
||||||
|
|
||||||
|
describe("rollSparkTable", () => {
|
||||||
|
const meta = parseSparkTableCsv(CSV)!;
|
||||||
|
|
||||||
|
test("non-remix reports the shared roll and reads the row it selected", () => {
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
const result = rollSparkTable(meta);
|
||||||
|
|
||||||
|
expect(result.remix).toBe(false);
|
||||||
|
expect(result.roll).toBeDefined();
|
||||||
|
|
||||||
|
const rolled = result.roll!.total;
|
||||||
|
expect(rolled).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(rolled).toBeLessThanOrEqual(12);
|
||||||
|
expect(result.roll!.detail).toBeTruthy();
|
||||||
|
|
||||||
|
// Every column shares the single roll.
|
||||||
|
for (const col of result.columns) {
|
||||||
|
expect(col.roll).toBe(rolled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The displayed value comes from the row matching the reported roll.
|
||||||
|
const row = meta.rows.find((r) => Number(r["2d6"]) === rolled);
|
||||||
|
expect(result.columns[0].value).toBe(row!["encounter"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remix gives each column its own roll and omits the shared roll", () => {
|
||||||
|
const result = rollSparkTable(meta, { remix: true });
|
||||||
|
|
||||||
|
expect(result.remix).toBe(true);
|
||||||
|
expect(result.roll).toBeUndefined();
|
||||||
|
|
||||||
|
for (const col of result.columns) {
|
||||||
|
expect(typeof col.roll).toBe("number");
|
||||||
|
expect(col.roll).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(col.roll).toBeLessThanOrEqual(12);
|
||||||
|
|
||||||
|
const row = meta.rows.find((r) => Number(r["2d6"]) === col.roll);
|
||||||
|
expect(col.value).toBe(row!["encounter"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
import Slugger from "github-slugger";
|
import Slugger from "github-slugger";
|
||||||
import { parse } from "csv-parse/browser/esm/sync";
|
import { parse } from "csv-parse/browser/esm/sync";
|
||||||
import { parseCSVString } from "../utils/csv-loader";
|
import { parseCSVString } from "../utils/csv-loader";
|
||||||
import { rollFormula } from "../md-commander/hooks";
|
import { rollFormula } from "../dice";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -25,8 +25,13 @@ export interface SparkTableColumn {
|
|||||||
header: string;
|
header: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
/** Dice total that selected this column's row. */
|
||||||
|
roll?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The dice-engine result for a single roll. */
|
||||||
|
export type SparkRollDetail = ReturnType<typeof rollFormula>["result"];
|
||||||
|
|
||||||
export interface SparkTableResult {
|
export interface SparkTableResult {
|
||||||
/** The dice notation (e.g. "d6", "d20") */
|
/** The dice notation (e.g. "d6", "d20") */
|
||||||
notation: string;
|
notation: string;
|
||||||
@@ -34,6 +39,10 @@ export interface SparkTableResult {
|
|||||||
columns: SparkTableColumn[];
|
columns: SparkTableColumn[];
|
||||||
/** Source file path */
|
/** Source file path */
|
||||||
source: string;
|
source: string;
|
||||||
|
/** True when each column rolled independently. */
|
||||||
|
remix: boolean;
|
||||||
|
/** Full dice detail for the shared roll — absent in remix mode. */
|
||||||
|
roll?: SparkRollDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SparkTableMeta {
|
export interface SparkTableMeta {
|
||||||
@@ -157,8 +166,9 @@ export function rollSparkTable(
|
|||||||
): SparkTableResult {
|
): SparkTableResult {
|
||||||
const slugger = new Slugger();
|
const slugger = new Slugger();
|
||||||
const columns: SparkTableColumn[] = [];
|
const columns: SparkTableColumn[] = [];
|
||||||
|
const remix = options.remix ?? false;
|
||||||
|
|
||||||
if (options.remix) {
|
if (remix) {
|
||||||
// Independent roll per column
|
// Independent roll per column
|
||||||
for (const header of meta.dataHeaders) {
|
for (const header of meta.dataHeaders) {
|
||||||
const slug = slugger.slug(header.toLowerCase());
|
const slug = slugger.slug(header.toLowerCase());
|
||||||
@@ -173,33 +183,37 @@ export function rollSparkTable(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
columns.push({ header, slug, value });
|
columns.push({ header, slug, value, roll: rolledValue });
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Single roll — all columns from the same row
|
|
||||||
const roll = rollFormula(meta.notation);
|
|
||||||
const rolledValue = roll.result.total;
|
|
||||||
|
|
||||||
let matchedRow: Record<string, string> | null = null;
|
|
||||||
for (const row of meta.rows) {
|
|
||||||
if (matchesCell(row[meta.diceHeader] ?? "", rolledValue)) {
|
|
||||||
matchedRow = row;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const header of meta.dataHeaders) {
|
return { notation: meta.notation, columns, source: "", remix };
|
||||||
const slug = slugger.slug(header.toLowerCase());
|
}
|
||||||
const value = matchedRow
|
|
||||||
? (matchedRow[header] ?? "")
|
// Single roll — all columns from the same row
|
||||||
: `(no row for ${rolledValue})`;
|
const roll = rollFormula(meta.notation);
|
||||||
columns.push({ header, slug, value });
|
const rolledValue = roll.result.total;
|
||||||
|
|
||||||
|
let matchedRow: Record<string, string> | null = null;
|
||||||
|
for (const row of meta.rows) {
|
||||||
|
if (matchesCell(row[meta.diceHeader] ?? "", rolledValue)) {
|
||||||
|
matchedRow = row;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const header of meta.dataHeaders) {
|
||||||
|
const slug = slugger.slug(header.toLowerCase());
|
||||||
|
const value = matchedRow
|
||||||
|
? (matchedRow[header] ?? "")
|
||||||
|
: `(no row for ${rolledValue})`;
|
||||||
|
columns.push({ header, slug, value, roll: rolledValue });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notation: meta.notation,
|
notation: meta.notation,
|
||||||
columns,
|
columns,
|
||||||
source: "",
|
source: "",
|
||||||
|
remix,
|
||||||
|
roll: roll.result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* Dev 工作流:运行 CLI 服务器并在 rsbuild 配置中设置代理
|
* Dev 工作流:运行 CLI 服务器并在 rsbuild 配置中设置代理
|
||||||
* > npm run tttk serve ./content
|
* > npm run tttk serve ./content
|
||||||
* > npm run dev
|
* > npm run dev
|
||||||
* rsbuild 会将 /__CONTENT_INDEX.json、/__COMPLETIONS.json、/content/ 代理到 CLI 服务器
|
* rsbuild 会将 /__CONTENT_INDEX.json、/__COMPLETIONS.json、/__CONTENT_REGISTRY.json、/content/ 代理到 CLI 服务器
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ tag: journal-set
|
|||||||
icon: 🔢
|
icon: 🔢
|
||||||
title: 变量系统
|
title: 变量系统
|
||||||
description: 在文档中声明变量和标签,通过 /set 命令设置变量值,自动计算派生变量和标签效果。
|
description: 在文档中声明变量和标签,通过 /set 命令设置变量值,自动计算派生变量和标签效果。
|
||||||
syntax: '```csv role=declare'
|
syntax: '```yaml role=declare'
|
||||||
props:
|
props:
|
||||||
- name: 定义文件
|
- name: 定义文件
|
||||||
type: —
|
type: —
|
||||||
desc: 在任意 .md 文档中使用 ```csv role=declare 代码块定义变量和标签
|
desc: 在任意 .md 文档中使用 ```yaml role=declare 代码块定义变量和标签
|
||||||
- name: 命令
|
- name: 命令
|
||||||
type: —
|
type: —
|
||||||
desc: /set $key expression | /set $key #tag1:count1;#tag2:count2
|
desc: /set $key expression | /set $key #tag1:count1;#tag2:count2
|
||||||
@@ -28,37 +28,48 @@ props:
|
|||||||
|
|
||||||
## 代码块语法
|
## 代码块语法
|
||||||
|
|
||||||
```csv role=declare
|
以 `yaml role=declare` 代码块定义,正文为 YAML:
|
||||||
tag,threshold,key,expr
|
|
||||||
,,$hp,$con*5+$mod_hp
|
```yaml role=declare
|
||||||
,,$ac,10+$dex
|
vars:
|
||||||
#warrior,1,$mod_hp,20
|
$hp: $con*5 + $mod_hp
|
||||||
#warrior,2,$mod_str,1
|
$ac: 10 + $dex
|
||||||
|
tags:
|
||||||
|
warrior:
|
||||||
|
1:
|
||||||
|
$mod_hp: 20
|
||||||
|
2:
|
||||||
|
$mod_str: 1
|
||||||
```
|
```
|
||||||
|
|
||||||
| 列 | 说明 |
|
| 键 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `tag` | 标签名(空表示普通变量声明) |
|
| `vars` | 变量声明映射:`$name: 表达式` |
|
||||||
| `threshold` | 激活阈值(可选,默认 `1`)。标签计数值 ≥ 阈值时激活 |
|
| `tags` | 标签修饰符映射:`标签名 → 阈值 → {$目标: 增量}` |
|
||||||
| `key` | 变量名,以 `$` 开头 |
|
|
||||||
| `expr` | 表达式,支持数字、`$var` 引用、骰子、算术、函数 |
|
标签名可以带或不带 `#` 前缀。阈值是正整数:标签计数值达到该值时,修饰符生效。
|
||||||
|
|
||||||
|
数字会被当作数值,其余一律作为表达式文本(如 `$con*5+$mod_hp`)。
|
||||||
|
|
||||||
### 声明类型
|
### 声明类型
|
||||||
|
|
||||||
**普通声明**(tag 为空):`$key` 是一个派生变量,其值由表达式计算。当依赖变量变化时自动重新计算。
|
**普通声明**(`vars`):`$key` 是一个派生变量,其值由表达式计算。当依赖变量变化时自动重新计算。
|
||||||
|
|
||||||
```csv role=declare
|
```yaml role=declare
|
||||||
tag,key,expr
|
vars:
|
||||||
,$hp,$con*5+$mod_hp
|
$hp: $con*5 + $mod_hp
|
||||||
,$ac,10+$dex
|
$ac: 10 + $dex
|
||||||
```
|
```
|
||||||
|
|
||||||
**标签修饰符**(tag 非空):当源变量的标签映射中 `#tag` 的计数值 ≥ `threshold` 时,`$key` 加上表达式的计算结果。修饰符按阈值独立激活。
|
**标签修饰符**(`tags`):当源变量的标签映射中 `#tag` 的计数值 ≥ 阈值时,`$key` 加上该值。同一标签的多个阈值会叠加,修饰符按阈值独立激活。
|
||||||
|
|
||||||
```csv role=declare
|
```yaml role=declare
|
||||||
tag,threshold,key,expr
|
tags:
|
||||||
#warrior,1,$mod_hp,20
|
warrior:
|
||||||
#warrior,2,$mod_str,1
|
1:
|
||||||
|
$mod_hp: 20
|
||||||
|
2:
|
||||||
|
$mod_str: 1
|
||||||
```
|
```
|
||||||
|
|
||||||
上例中:
|
上例中:
|
||||||
@@ -141,10 +152,13 @@ tag,threshold,key,expr
|
|||||||
|
|
||||||
阈值允许同一标签产生不同层级的效果:
|
阈值允许同一标签产生不同层级的效果:
|
||||||
|
|
||||||
```csv role=declare
|
```yaml role=declare
|
||||||
tag,threshold,key,expr
|
tags:
|
||||||
#warrior,1,$mod_hp,10
|
warrior:
|
||||||
#warrior,3,$mod_hp,20
|
1:
|
||||||
|
$mod_hp: 10
|
||||||
|
3:
|
||||||
|
$mod_hp: 20
|
||||||
```
|
```
|
||||||
|
|
||||||
- `#warrior` 计数 1-2:`$mod_hp` +10
|
- `#warrior` 计数 1-2:`$mod_hp` +10
|
||||||
@@ -174,14 +188,18 @@ tag,threshold,key,expr
|
|||||||
|
|
||||||
在文档中定义:
|
在文档中定义:
|
||||||
|
|
||||||
```csv role=declare
|
```yaml role=declare
|
||||||
tag,threshold,key,expr
|
vars:
|
||||||
,,$con,10
|
$con: 10
|
||||||
,,$dex,12
|
$dex: 12
|
||||||
,,$hp,$con*5+$mod_hp
|
$hp: $con*5 + $mod_hp
|
||||||
,,$ac,10+$dex
|
$ac: 10 + $dex
|
||||||
#warrior,1,$mod_hp,20
|
tags:
|
||||||
#warrior,2,$mod_str,1
|
warrior:
|
||||||
|
1:
|
||||||
|
$mod_hp: 20
|
||||||
|
2:
|
||||||
|
$mod_str: 1
|
||||||
```
|
```
|
||||||
|
|
||||||
在 Journal 中输入:
|
在 Journal 中输入:
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
---
|
|
||||||
tag: md-commander
|
|
||||||
icon: 📋
|
|
||||||
title: 命令追踪器
|
|
||||||
---
|
|
||||||
|
|
||||||
支持命令历史和游戏状态追踪,使用类 Emmet 语法创建追踪项,支持 CSV 命令模板加载。
|
|
||||||
|
|
||||||
**语法:** `:md-commander[./templates.csv]{选项}`
|
|
||||||
|
|
||||||
**追踪 NPC 血量和防御:**
|
|
||||||
```
|
|
||||||
track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 语法规则
|
|
||||||
|
|
||||||
- `#id` 设置 ID
|
|
||||||
- `.class` 添加类别
|
|
||||||
- `[attr=value]` 设置属性
|
|
||||||
|
|
||||||
## 属性类型
|
|
||||||
|
|
||||||
| 格式 | 显示 |
|
|
||||||
|---|---|
|
|
||||||
| `x/y` | 进度条 |
|
|
||||||
| 整数 | 计数器 |
|
|
||||||
| 文本 | 文本字段 |
|
|
||||||
|
|
||||||
## 视图模式
|
|
||||||
|
|
||||||
组件有两个标签页,通过顶部标签栏切换:
|
|
||||||
|
|
||||||
- **历史**:显示命令执行历史,点击可重新填入命令
|
|
||||||
- **追踪**:显示当前追踪的所有项目,支持属性编辑、类管理、排序和删除
|
|
||||||
|
|
||||||
## 键盘快捷键
|
|
||||||
|
|
||||||
| 快捷键 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `Enter` | 执行命令 |
|
|
||||||
| `Tab` | 接受自动补全 |
|
|
||||||
| `↑` / `↓` | 补全列表导航 / 命令历史导航 |
|
|
||||||
| `Escape` | 关闭补全菜单 |
|
|
||||||
|
|
||||||
## 属性
|
|
||||||
|
|
||||||
| 属性 | 类型 | 默认值 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `placeholder` | string | `"输入命令..."` | 输入框占位符 |
|
|
||||||
| `class` | string | — | 额外 CSS 类 |
|
|
||||||
| `height` | string | `"400px"` | 组件高度 |
|
|
||||||
| `commandTemplates` | string | — | CSV 模板文件路径 |
|
|
||||||
|
|
||||||
## 使用场景
|
|
||||||
|
|
||||||
用于追踪战斗中的 NPC 血量、AC、状态等游戏信息。
|
|
||||||
@@ -14,10 +14,16 @@ export interface BlockAttrs {
|
|||||||
extra: Record<string, string>;
|
extra: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse key="value" and key=value pairs from an attribute string. */
|
/**
|
||||||
|
* Parse key="value" and key=value pairs from an attribute string.
|
||||||
|
*
|
||||||
|
* Keys may contain hyphens (e.g. `data-config=...`); the key must start with a
|
||||||
|
* letter or underscore so a leading `-` is treated as part of a value, not a
|
||||||
|
* key.
|
||||||
|
*/
|
||||||
export function parseBlockAttrs(info: string): BlockAttrs {
|
export function parseBlockAttrs(info: string): BlockAttrs {
|
||||||
const attrs: Record<string, string> = {};
|
const attrs: Record<string, string> = {};
|
||||||
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
|
const re = /([A-Za-z_][\w-]*)\s*=\s*("[^"]*"|\S+)/g;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = re.exec(info)) !== null) {
|
while ((m = re.exec(info)) !== null) {
|
||||||
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
|
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ describe("code-block-yaml-tag", () => {
|
|||||||
expect(token).toBeUndefined();
|
expect(token).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("supports tilde fences", () => {
|
||||||
|
const html = render("~~~yaml role=tag\ntag: md-deck\nbody: ./cards.csv\n~~~");
|
||||||
|
expect(html).toContain("<md-deck>./cards.csv</md-deck>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not swallow plain tilde yaml code blocks", () => {
|
||||||
|
const token = ext.tokenizer("~~~yaml\nsize: 54x86\n~~~");
|
||||||
|
expect(token).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
test("does not swallow yaml blocks without role=tag", () => {
|
test("does not swallow yaml blocks without role=tag", () => {
|
||||||
const token = ext.tokenizer("```yaml tag=md-deck\nsize: 54x86\n```");
|
const token = ext.tokenizer("```yaml tag=md-deck\nsize: 54x86\n```");
|
||||||
expect(token).toBeUndefined();
|
expect(token).toBeUndefined();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { parseBlockAttrs } from "./block-attrs.js";
|
|||||||
* `role=tag` on the info string marks the block (and lets the content
|
* `role=tag` on the info string marks the block (and lets the content
|
||||||
* scanner keep it intact). `tag=` / `id=` / any other `key=value` fence
|
* scanner keep it intact). `tag=` / `id=` / any other `key=value` fence
|
||||||
* attributes override the same keys in the YAML body, so simple cases
|
* attributes override the same keys in the YAML body, so simple cases
|
||||||
* stay on one line.
|
* stay on one line. Both backtick and tilde fences are accepted.
|
||||||
*/
|
*/
|
||||||
export default function markedCodeBlockYamlTag(): MarkedExtension {
|
export default function markedCodeBlockYamlTag(): MarkedExtension {
|
||||||
return {
|
return {
|
||||||
@@ -22,13 +22,13 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
|
|||||||
name: "code-block-yaml-tag",
|
name: "code-block-yaml-tag",
|
||||||
level: "block",
|
level: "block",
|
||||||
start(src: string) {
|
start(src: string) {
|
||||||
return src.match(/^```yaml\s+role=tag(?:\s|\n)/m)?.index;
|
return src.match(/^(?:`{3,}|~{3,})yaml\s+role=tag(?:\s|\n)/m)?.index;
|
||||||
},
|
},
|
||||||
tokenizer(src: string) {
|
tokenizer(src: string) {
|
||||||
const rule = /^```yaml\s+role=tag([^\n]*)\n([\s\S]*?)\n```/;
|
const rule = /^([`~])\1{2,}yaml\s+role=tag([^\n]*)\n([\s\S]*?)\n\1{3,}/;
|
||||||
const match = rule.exec(src);
|
const match = rule.exec(src);
|
||||||
if (match) {
|
if (match) {
|
||||||
const yamlContent = match[2]?.trim() || "";
|
const yamlContent = match[3]?.trim() || "";
|
||||||
let yamlProps: Record<string, unknown> = {};
|
let yamlProps: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
yamlProps =
|
yamlProps =
|
||||||
@@ -39,7 +39,7 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fence attributes override YAML body values.
|
// Fence attributes override YAML body values.
|
||||||
const attrs = parseBlockAttrs(match[1] || "");
|
const attrs = parseBlockAttrs(match[2] || "");
|
||||||
const fenceProps: Record<string, unknown> = {
|
const fenceProps: Record<string, unknown> = {
|
||||||
...(attrs.id ? { id: attrs.id } : {}),
|
...(attrs.id ? { id: attrs.id } : {}),
|
||||||
...attrs.extra,
|
...attrs.extra,
|
||||||
|
|||||||
@@ -25,3 +25,7 @@ each interaction should be implemented as a command entry in the history view.
|
|||||||
### 新增功能
|
### 新增功能
|
||||||
|
|
||||||
- [x] 支持 Emmet 简写语法:`track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]`
|
- [x] 支持 Emmet 简写语法:`track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]`
|
||||||
|
|
||||||
|
## variable store(./src/components/journal)
|
||||||
|
|
||||||
|
- [ ] fix the outstanding issues from the reactivity audit: [`docs/variable-store-plan.md`](./docs/variable-store-plan.md)
|
||||||
Reference in New Issue
Block a user