Compare commits

...
18 Commits
Author SHA1 Message Date
hyper f9ea37427b docs(content): add inline spark-table example 2026-09-12 23:58:49 +08:00
hyper ebb49ddfca docs(todo): track variable store audit 2026-09-12 23:58:44 +08:00
hyper 322287da29 docs(reference): cover bare dice and tilde fences
Add the bare d-form (d20) to the dice notation table, and note that role fences accept backticks or tildes with hyphen-capable attribute keys. Regenerate both reference copies from known.ts and mirror the notes in the skill and markdown docs.
2026-09-12 23:56:12 +08:00
hyper 08bc97bec5 fix(markdown): accept tilde fences when scanning
Fenced block scanning was backtick-only in FENCED_BLOCK_RE (used by scanDoc) and in the yaml role=tag render extension, while stripFencedBlocks and the validator already accepted tildes. Match both marker styles and require the closing fence to reuse the opening marker.
2026-09-12 23:45:01 +08:00
hyper 264038e27c fix(markdown): keep hyphenated block attribute keys
The key pattern \w+ could not cross a hyphen, so data-config=foo was parsed as config=foo and the data- prefix was lost. Allow hyphens in keys, matching parseDirectiveAttrs.
2026-09-12 23:44:43 +08:00
hyper e838f9a4b1 fix(journal): tokenize bare dice notation
The tokenizer only recognized dice when the pattern started with a count (3d6), so a bare d20 fell through to the identifier branch and failed as an unknown identifier. Add a dedicated branch for the bare d-form; DICE_RE already accepted it.
2026-09-12 23:44:24 +08:00
hyper 3532be2335 fix(journal): re-publish only changed declarations
topoSortAffected followed declared dependencies even when they were not in the affected set, so reevaluateDependents re-evaluated and re-published variables that had not changed. This could also overwrite a value just set via /set with the declaration's own expression.
2026-09-12 23:44:06 +08:00
hyper f17f09a8c4 refactor(components): remove md-commander widget
The journal now owns command history and tracking, so retire the md-commander widget. Its dice engine was extracted to src/components/dice in the previous commit.

Removes the custom element, tracker/command stores, completions, useCommander, Emmet parser, the doc entry, test fixtures, and the known-element catalog entry (reference docs regenerated).

BREAKING CHANGE: :md-commander is no longer a known element; content using it now fails tttk check with TTTK-D001.
2026-09-12 23:29:26 +08:00
hyper 3508286792 refactor(dice): extract engine from md-commander
The dice engine lived under md-commander/hooks but is shared by :md-dice, spark tables, the journal /roll command, and dice inside variable expressions. Move it to src/components/dice so the md-commander widget can be retired without losing rolling.

Also promote the browser-console harness to a real jest suite; the file was named test.ts, so jest never ran it.
2026-09-12 23:14:28 +08:00
hyper 0b029b325e docs(declare): document the YAML declare format
Update the journal-set doc entry, the CLI diagnostic table, and the
authoring skill, along with the in-app empty-state hint and the var
message comment.
2026-09-12 22:51:12 +08:00
hyper 0ac9a833e4 docs(reference): regenerate declare block docs
Regenerate `docs/reference.md` and the skill copy from `known.ts`.
2026-09-12 22:51:00 +08:00
hyper fadfd4cba6 feat(declare)!: parse role=declare blocks as YAML
Replace the tag/threshold/key/expr CSV body with YAML `vars:` and
`tags:` maps. `parseDeclare` validates the body with an exported Zod
schema and returns the same declaration and modifier shapes the
reactivity engine already consumes.

The breaking rename from `parseDeclareCsv` updates the registry, the
journal re-exports, and the `tttk check` role checks, where R003 now
reports schema errors with their path and R004 warns when a block
declares nothing.
2026-09-12 22:50:48 +08:00
hyper 16f2038fd8 fix(journal): unify $var lookup and error
EvalContext.lookup now receives the full $var name, matching getCombined,
setBase, and getMods, and parseFactor throws "Unknown variable" when the
lookup returns undefined. /set and /roll use the new resolveVariable() so a
typo errors instead of silently evaluating to 0.

Declarations and tag modifiers stay lenient (unknown -> 0): the documented
$hp = $con*5+$mod_hp pattern depends on external/undeclared variables
defaulting to 0.
2026-09-12 22:34:12 +08:00
hyper 02442a1100 fix(journal): re-init reactivity and drop var del
Make the reactivity engine re-initialize when the loaded declarations
change instead of only at import. ensureReactivity() reads the live
completions signal, compares a signature, and rebuilds from the store
(with exact bases) only when needed; dispatchCommand calls it before each
dispatch. Remove the unused DispatchContext.declarations/tagModifiers
fields, which were mount-time snapshots and could not be trusted.

Build initReactivity's maps into locals and commit only after the
circular check passes, so a failed re-init leaves the previous engine
intact; clear inFlight on commit.

Drop the unreachable var "del" action from the schema, render, and
reducer.
2026-09-12 22:29:25 +08:00
hyper 9f372fa2b0 fix(journal): surface variable eval errors
Collect and report failed declarations, failed tag modifier expressions, and
circular dependencies instead of swallowing them. Diagnostics are deduped,
logged with key/expression context, cleared on re-init, and exposed through
useJournalCompletions and the VariableView warning banner. A bad declaration
no longer aborts the rest of a cascade.

Reject malformed numeric literals in the tokenizer so inputs like "1.2.3"
throw instead of being truncated by parseFloat.
2026-09-12 22:22:18 +08:00
hyper a26d53cbeb fix(journal): keep variable bases in the stream
The stream only carried combined values, so hydrating a session lost the raw base and rebuildReactivityFromStore re-applied tag modifiers on top of already-combined values (e.g. $hp 70 became 90).

Publish the raw base alongside the combined value on var messages, persist it in variableBases, and rebuild engine state from exact bases. Derive tag activations from base tagmaps so tagmap-valued modifier targets are not re-triggered on every rebuild.

Seed declared variables only after hydration and only for keys absent from the store, so persisted values are no longer clobbered on load and reloads stop appending duplicate var messages.
2026-09-12 21:59:45 +08:00
hyper 0ce91afc49 feat(journal): show spark roll dice and markdown
The spark message never displayed the dice, and its rollResult came from a
second, independent roll that could disagree with the row actually
selected.

rollSparkTable now reports the roll(s) it used, so the message can show
'header / notation -> roll' in the purple box and render each rolled value
as markdown (its own div) below it.
2026-09-12 20:43:03 +08:00
hyper c7f3b3425b fix(dev): proxy content registry to CLI server
Inline spark tables (role=spark-table) live in the content registry, but the
rsbuild dev proxy forwarded only /__COMPLETIONS.json and
/__CONTENT_INDEX.json. The registry fetch fell back to the SPA index.html,
so /roll resolved the inline CSV as HTML and csv-parse failed with an
invalid opening quote.

Load the registry before publishing the loaded completions state so a roll
cannot fire while inline content is still unresolvable.
2026-09-12 20:30:57 +08:00
77 changed files with 1761 additions and 2913 deletions
+6 -4
View File
@@ -1,6 +1,6 @@
---
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
@@ -54,13 +54,15 @@ in `reference.md`.
## 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 |
|---|---|
| ` ```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. |
| ` ```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. |
| ` ```yaml role=tag ` | Kept intact; rendered as a custom element from YAML (`tag:`, `body:`, …). |
| ` ```<lang> ` (no role) | Rendered as a normal visible code block. |
@@ -109,7 +111,7 @@ Warnings are usually real too:
| Code | Meaning |
|---|---|
| `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-L002` | `#section` matches no heading |
| `TTTK-A001` | icon missing under `assets/` |
+16 -19
View File
@@ -54,7 +54,6 @@ Nest with more colons (`::::`, `:::::`).
| `:md-border` | color or border image | `width`, `slice` |
| `:md-font` | font family | `source`, `weight`, `color` |
| `:md-pins` | image path | `pins`, `fixed`, `labelStart` |
| `:md-commander` | command template CSV | `placeholder`, `class`, `height` |
| `:md-yarn-spinner` | Yarn Spinner file | `start` |
### `:md-dice`
@@ -205,19 +204,6 @@ Numbered markers on an image.
: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`
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
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`
CSV (or markdown pipe table) rendered as `:md-table`.
@@ -297,13 +287,19 @@ d6,结果
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
```csv role=declare
tag,threshold,key,expr
,,$hp,$con*5+$mod_hp
#warrior,2,$mod_hp,20
```yaml role=declare
vars:
$hp: $con*5 + $mod_hp
$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 |
| `1d20+5` | d20 plus modifier |
| `d20` | one twenty-sided die (count optional) |
| `3d6kh1` | keep highest 1 |
| `3d6dl2-1` | drop lowest 2, minus 1 |
| `{d4, d8}` | choose one of the listed dice |
+2 -2
View File
@@ -2,8 +2,8 @@
`` ```yaml role=tag `` renders a custom element from YAML. `tag` names the
element, `body` becomes its content, and any other key becomes an attribute.
Fence-line attributes override the YAML body. See `reference.md` for the
element and role catalog.
Fence-line attributes override the YAML body; both backtick and tilde (`~~~`)
fences work. See `reference.md` for the element and role catalog.
````markdown
```yaml role=tag
-1
View File
@@ -38,7 +38,6 @@ npx tttk check ./content
| 📊 表格 | `:md-table[./data.csv]` | 标签页式表格 |
| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局(推荐 `yaml role=tag` 写法) |
| 📍 标记 | `:md-pins[./map.png]{pins="A:25,50"}` | 图片标记 |
| 📋 追踪器 | `:md-commander[./commands.csv]` | 命令历史和状态追踪 |
## 技术栈
-15
View File
@@ -1,15 +0,0 @@
# Commander Test
:md-commander[./commands.csv]
## Lonelog
`我` 走进酒吧。
`oracle` 酒吧里人多吗?
`roll` d20 = 18 `prompt` 是的
`fact` 酒吧里有很多人。
:[blah]
-6
View File
@@ -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]"
1 command parameter label description insertedText
2 roll emmet 2d6 2D6 2d6
3 roll emmet d20 D20 d20
4 track emmet monk 破戒佛爷 破戒佛爷.bandit.monk[gd=4/4,str=14/14,cla=10,spi=8]
5 track emmet bandit 龙虎寨山贼 龙虎寨山贼.bandit[gd=2/2,str=10/10,cla=10,spi=10]
6 track emmet khitan 契丹护卫 契丹护卫.khitan.guard[gd=4/4,ap=2,str=14/14,cla=14,spi=14]
+15
View File
@@ -33,6 +33,21 @@ bler
: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#啊什么]的啦
+2 -2
View File
@@ -115,8 +115,8 @@ content/adventures/hollow-mill/adventure.md
| `TTTK-D002` | error | 属性块畸形(例如未加引号的值中含空格) |
| `TTTK-R001` | error | 未知的代码块 `role=`(块内容会被静默丢弃) |
| `TTTK-R002` | warning | `role=spark-table` 首列表头不是骰点公式 |
| `TTTK-R003` | error | `role=declare` 解析失败(缺少 `key`/`expr` 等) |
| `TTTK-R004` | warning | `role=declare` 中 `key`/`expr` 为空的行被跳过 |
| `TTTK-R003` | error | `role=declare` 解析失败(YAML 非法或结构不符) |
| `TTTK-R004` | warning | `role=declare` 块没有声明任何变量或标签修饰符 |
| `TTTK-R005` | error | `role=tag` 的 YAML 无法解析 |
| `TTTK-R006` | error | `role=tag` 缺少 `tag:` 或元素未知 |
| `TTTK-C001` | error | 内容引用无法解析(文件不存在,或内联 id 未定义) |
+1 -2
View File
@@ -46,7 +46,6 @@ tttk/
│ │ ├── md-table.tsx # 表格组件
│ │ ├── md-deck/ # 卡牌组件
│ │ ├── md-pins.tsx # 标记组件
│ │ ├── md-commander/ # 命令追踪器
│ │ ├── md-yarn-spinner.tsx # 叙事线组件
│ │ ├── Article.tsx # 文章组件
@@ -253,7 +252,7 @@ npm test -- --watch
- Web 前端:rsbuild 开发服务器,端口 **5173**
- 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,否则前端拿不到内容。
### Web 开发
+5 -30
View File
@@ -11,6 +11,10 @@
- [marked-gfm-heading-id](https://github.com/markedjs/marked-gfm-heading-id) - 标题 ID
- [marked-directive](https://github.com/fengzilong/marked-directive) - 指令语法
### 代码块围栏
代码块围栏可使用反引号或波浪号(`~~~`)书写;闭合围栏必须与开启围栏使用相同字符,且不短于开启围栏。声明 `role=` 的代码块同理。
## 指令语法
通过 `marked-directive` 支持自定义组件插入:
@@ -123,6 +127,7 @@
- 点击文本执行掷骰
- 点击骰子图标重置为公式
- `key` 属性将结果记录到 URL 参数中 (`?dice-attack=15`)
- 骰子数量可省略,`d20` 等价于 `1d20`
**属性:**
@@ -461,36 +466,6 @@ data-config:
: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 role=tag 代码块创建自定义标签(`yaml` 语言可被语法高亮):
-2
View File
@@ -335,8 +335,6 @@ MCP 服务器提供以下 Resources,包含 TTRPG Tools 的文档和参考材
- `:md-table` - 表格组件
- `:md-deck` - 卡牌组件
- `:md-yarn-spinner` - 叙事线组件
- `:md-commander` - 命令追踪器
- YAML 标签
- Mermaid 图表
- 警告/提示块
+16 -19
View File
@@ -54,7 +54,6 @@ Nest with more colons (`::::`, `:::::`).
| `:md-border` | color or border image | `width`, `slice` |
| `:md-font` | font family | `source`, `weight`, `color` |
| `:md-pins` | image path | `pins`, `fixed`, `labelStart` |
| `:md-commander` | command template CSV | `placeholder`, `class`, `height` |
| `:md-yarn-spinner` | Yarn Spinner file | `start` |
### `:md-dice`
@@ -205,19 +204,6 @@ Numbered markers on an image.
: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`
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
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`
CSV (or markdown pipe table) rendered as `:md-table`.
@@ -297,13 +287,19 @@ d6,结果
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
```csv role=declare
tag,threshold,key,expr
,,$hp,$con*5+$mod_hp
#warrior,2,$mod_hp,20
```yaml role=declare
vars:
$hp: $con*5 + $mod_hp
$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 |
| `1d20+5` | d20 plus modifier |
| `d20` | one twenty-sided die (count optional) |
| `3d6kh1` | keep highest 1 |
| `3d6dl2-1` | drop lowest 2, minus 1 |
| `{d4, d8}` | choose one of the listed dice |
+149
View File
@@ -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.
+1
View File
@@ -55,6 +55,7 @@ export default defineConfig({
// Proxy content API requests to the CLI dev server (npm run tttk)
"/__CONTENT_INDEX.json": "http://localhost:3000",
"/__COMPLETIONS.json": "http://localhost:3000",
"/__CONTENT_REGISTRY.json": "http://localhost:3000",
"/content": "http://localhost:3000",
"/.ttrpg": "http://localhost:3000",
},
+6 -2
View File
@@ -22,5 +22,9 @@ export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js"
// 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;
+133 -57
View File
@@ -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):
* 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)
* The body is YAML with two optional maps (`declareSchema` below):
*
* When `tag` is empty: $key is a reactively computed variable.
* When `tag` is present: when #tag met (source's tagmap value >= threshold),
* $key gets expr added to its base.
* vars:
* $hp: $con*5 + $mod_hp
* $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
@@ -38,65 +45,134 @@ export interface DeclareResult {
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 trimmed = csv.trim();
const exprSchema = z
.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: [] };
// Validate that required columns exist (order-agnostic, tag is optional)
const firstLine = trimmed.split(/\r?\n/)[0];
const headers = firstLine.split(",").map((h) => h.trim().toLowerCase());
if (!headers.includes("key") || !headers.includes("expr")) {
let data: unknown;
try {
data = yaml.load(trimmed);
} catch (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, {
columns: true,
trim: true,
skipEmptyLines: true,
}) as Array<{ tag?: string; threshold?: string; key: string; expr: string }>;
const parsed = declareSchema.safeParse(data ?? {});
if (!parsed.success) {
throw new Error(`role=declare: ${formatIssues(parsed.error)}`);
}
const variables: VarDeclaration[] = [];
for (const [key, expression] of Object.entries(parsed.data.vars ?? {})) {
variables.push({ key, expression });
}
const tagModifiers: TagModifier[] = [];
for (const row of records) {
const tag = row.tag ?? "";
const key = row.key ?? "";
const expr = row.expr ?? "";
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}"`);
for (const [name, thresholds] of Object.entries(parsed.data.tags ?? {})) {
const tag = name.startsWith("#") ? name : `#${name}`;
for (const [rawThreshold, targets] of Object.entries(thresholds)) {
const threshold = Number(rawThreshold);
for (const [target, expression] of Object.entries(targets)) {
tagModifiers.push({ tag, target, expression, threshold });
}
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 };
}
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);
}
+467 -129
View File
@@ -1,6 +1,6 @@
/**
* Tests for the variable/tag system:
* - declare-parser (CSV parsing)
* - declare-parser (YAML parsing)
* - block-scanner (attribute parsing)
* - variable-expression (expression evaluation)
* - var-reactivity (dependency graph, cascade, tag activation)
@@ -27,7 +27,7 @@ jest.mock("github-slugger", () => {
// Imports
// ---------------------------------------------------------------------------
import { parseDeclareCsv } from "./declare-parser";
import { parseDeclare } from "./declare-parser";
import { parseBlockAttrs } from "./block-scanner";
import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
import {
@@ -39,6 +39,9 @@ import {
getMods,
getDeclExpr,
extractDependencies,
rebuildReactivityFromStore,
getReactivityDiagnostics,
resolveVariable,
} from "../../components/journal/var-reactivity";
import { parseInput } from "../../components/journal/command-parser";
import {
@@ -60,154 +63,154 @@ function store(entries: Record<string, string> = {}): Record<string, string> {
// declare-parser
// ---------------------------------------------------------------------------
describe("parseDeclareCsv", () => {
describe("parseDeclare", () => {
test("parses variable declarations", () => {
const csv = `tag,key,expr
,$hp,$con*5+$mod_hp
,$ac,10+$dex`;
const result = parseDeclareCsv(csv, "test.md");
expect(result.variables).toHaveLength(2);
expect(result.variables[0]).toEqual({
key: "$hp",
expression: "$con*5+$mod_hp",
});
expect(result.variables[1]).toEqual({ key: "$ac", expression: "10+$dex" });
const result = parseDeclare(`
vars:
$hp: $con*5 + $mod_hp
$ac: 10 + $dex
`);
expect(result.variables).toEqual([
{ key: "$hp", expression: "$con*5 + $mod_hp" },
{ key: "$ac", expression: "10 + $dex" },
]);
expect(result.tagModifiers).toHaveLength(0);
});
test("parses tag modifiers", () => {
const csv = `tag,key,expr
#warrior,$mod_hp,20
#mage,$mod_mp,15`;
const result = parseDeclareCsv(csv, "test.md");
test("parses tag modifiers with thresholds", () => {
const result = parseDeclare(`
tags:
warrior:
2:
$mod_hp: 20
mage:
1:
$mod_mp: 15
`);
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({
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
});
expect(result.tagModifiers[1]).toEqual({
tag: "#mage",
target: "$mod_mp",
expression: "15",
threshold: 1,
});
});
test("parses tag modifiers with threshold", () => {
const csv = `tag,threshold,key,expr
#warrior,2,$mod_hp,20
#mage,,$mod_mp,15`;
const result = parseDeclareCsv(csv, "test.md");
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("normalizes numeric values to expression text", () => {
const result = parseDeclare("vars:\n $con: 10\n $dex: 12\n");
expect(result.variables).toEqual([
{ key: "$con", expression: "10" },
{ key: "$dex", expression: "12" },
]);
});
test("rejects invalid threshold", () => {
const csv = `tag,threshold,key,expr
#warrior,0,$mod_hp,20`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
"threshold must be a positive integer",
);
test("YAML numeric forms are resolved before stringification", () => {
const result = parseDeclare("vars:\n $a: 0x1F\n $b: 1e3\n");
expect(result.variables).toEqual([
{ key: "$a", expression: "31" },
{ key: "$b", expression: "1000" },
]);
});
test("parses mixed declarations and tag modifiers", () => {
const csv = `tag,key,expr
,$hp,$con*5
#warrior,$mod_hp,20
,$ac,10+$dex`;
const result = parseDeclareCsv(csv, "test.md");
const result = parseDeclare(`
vars:
$hp: $con*5
$ac: 10+$dex
tags:
warrior:
1:
$mod_hp: 20
`);
expect(result.variables).toHaveLength(2);
expect(result.tagModifiers).toHaveLength(1);
});
test("rejects missing key column", () => {
const csv = `tag,expr
,$con*5`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'role=declare blocks must have "key" and "expr" columns',
test("handles empty, whitespace, and comment-only bodies", () => {
for (const body of ["", " \n ", "# just a comment\n"]) {
expect(parseDeclare(body)).toEqual({ variables: [], tagModifiers: [] });
}
});
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", () => {
const csv = `tag,key
,$hp`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'role=declare blocks must have "key" and "expr" columns',
test("rejects unknown top-level keys", () => {
expect(() => parseDeclare("variable:\n $hp: 1\n")).toThrow(
"Unrecognized key",
);
});
test("rejects tag without # prefix", () => {
const csv = `tag,key,expr
warrior,$mod_hp,20`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
"tag must start with #",
test("rejects a variable name without $", () => {
expect(() => parseDeclare("vars:\n hp: 1\n")).toThrow(
"variable names must start with $",
);
});
test("rejects key without $ prefix in declaration", () => {
const csv = `tag,key,expr
,hp,$con*5`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
"key must start with $",
test("rejects a tag modifier target without $", () => {
expect(() =>
parseDeclare("tags:\n warrior:\n 1:\n mod_hp: 20\n"),
).toThrow("tag modifier targets 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", () => {
const csv = `tag,key,expr
#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,
});
test("rejects non-numeric, non-string values", () => {
expect(() => parseDeclare("vars:\n $hp: true\n")).toThrow();
});
});
@@ -226,16 +229,26 @@ describe("parseBlockAttrs", () => {
});
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.role).toBe("declare");
});
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" });
});
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", () => {
const attrs = parseBlockAttrs("");
expect(attrs.lang).toBe("");
@@ -271,32 +284,31 @@ describe("evaluateExpression", () => {
test("evaluates variable references", () => {
const result = evaluateExpression("$con * 5 + $mod_hp", {
lookup: (name) => {
if (name === "con") return "12";
if (name === "mod_hp") return "20";
if (name === "$con") return "12";
if (name === "$mod_hp") return "20";
return undefined;
},
});
expect(result.value).toEqual({ kind: "number", value: 80 }); // 12*5 + 20
});
test("returns 0 for undefined variables", () => {
const result = evaluateExpression("$unknown + 5", {
lookup: () => undefined,
});
expect(result.value).toEqual({ kind: "number", value: 5 });
test("throws on unknown variables", () => {
expect(() =>
evaluateExpression("$unknown + 5", { lookup: () => undefined }),
).toThrow("Unknown variable: $unknown");
});
test("throws on type mismatch (tagmap + number)", () => {
expect(() =>
evaluateExpression("$class + 5", {
lookup: (name) => (name === "class" ? "#warrior" : undefined),
lookup: (name) => (name === "$class" ? "#warrior" : undefined),
}),
).toThrow("Type mismatch");
});
test("resolves tagmap variable without arithmetic", () => {
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 } });
});
@@ -377,6 +389,20 @@ describe("evaluateExpression", () => {
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", () => {
expect(() =>
evaluateExpression("10 / 0", { lookup: () => undefined }),
@@ -395,6 +421,15 @@ describe("evaluateExpression", () => {
).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", () => {
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "number", value: 6 });
@@ -411,8 +446,8 @@ describe("evaluateExpression", () => {
test("complex expression with variables and functions", () => {
const result = evaluateExpression("floor($con / 2) + $mod", {
lookup: (name) => {
if (name === "con") return "15";
if (name === "mod") return "3";
if (name === "$con") return "15";
if (name === "$mod") return "3";
return undefined;
},
});
@@ -506,6 +541,33 @@ describe("var-reactivity", () => {
}),
).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", () => {
@@ -539,6 +601,33 @@ describe("var-reactivity", () => {
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", () => {
initReactivity({ declarations: [], tagModifiers: [] });
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", () => {
test("returns base value when no mods", () => {
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", () => {
test("activates tag modifiers when a tag value is set", () => {
initReactivity({
@@ -605,6 +922,27 @@ describe("var-reactivity", () => {
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", () => {
initReactivity({
declarations: [],
+29
View File
@@ -29,6 +29,35 @@ describe("scanDoc", () => {
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", () => {
const md = [
"```markdown role=spark-table",
+3 -2
View File
@@ -17,7 +17,7 @@
import Slugger from "github-slugger";
import {
parseDeclareCsv,
parseDeclare,
type VarDeclaration,
type TagModifier,
} from "./completions/declare-parser.js";
@@ -124,6 +124,7 @@ export function scanDoc(content: string, docPath: string): DocScanResult {
FENCED_BLOCK_RE,
(
match: string,
_marker: string,
lang: string,
infoString: string,
body: string,
@@ -471,7 +472,7 @@ export function deriveBlocks(registry: ContentRegistry): {
for (const entry of Object.values(store)) {
if (entry.kind !== "declare") continue;
try {
const result = parseDeclareCsv(entry.body, docPath);
const result = parseDeclare(entry.body);
declarations.push(...result.variables);
tagModifiers.push(...result.tagModifiers);
} catch (e) {
+24 -37
View File
@@ -2,8 +2,7 @@
* Code-block role checks.
*/
import { parse } from "csv-parse/browser/esm/sync";
import { parseDeclareCsv } from "../../completions/declare-parser.js";
import { parseDeclare, type DeclareResult } from "../../completions/declare-parser.js";
import { diagnosticAt } from "../diagnostics.js";
import { ELEMENT_NAMES, KNOWN_ROLES } from "../known.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 {
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 {
parseDeclareCsv(fence.body, file);
result = parseDeclare(fence.body);
} catch (error) {
sink.report(
diagnosticAt(
@@ -71,51 +87,22 @@ function checkDeclare(fence: RawFence, ctx: CheckContext): void {
"TTTK-R003",
"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;
}
checkDeclareRows(fence, ctx);
}
/**
* 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;
if (result.variables.length === 0 && result.tagModifiers.length === 0) {
sink.report(
diagnosticAt(
file,
source,
source.lineStart(baseLine + info.lines - 1),
fence.offset,
"TTTK-R004",
"warning",
`declare row skipped: ${!key ? "empty key" : "empty expr"}`,
"every declare row needs both a key and an expression",
"role=declare block declares nothing",
"add a `vars:` or `tags:` mapping; the block has no effect",
),
);
}
+4 -13
View File
@@ -131,16 +131,6 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
':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",
body: "Yarn Spinner file",
@@ -232,12 +222,12 @@ export const ROLE_SPECS: readonly RoleSpec[] = [
},
{
role: "declare",
info: "csv role=declare",
info: "yaml role=declare",
summary: "Reactive variable / tag-modifier definitions.",
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: [
"```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[] = [
{ notation: "2d6", meaning: "two six-sided dice" },
{ notation: "1d20+5", meaning: "d20 plus modifier" },
{ notation: "d20", meaning: "one twenty-sided die (count optional)" },
{ notation: "3d6kh1", meaning: "keep highest 1" },
{ notation: "3d6dl2-1", meaning: "drop lowest 2, minus 1" },
{ notation: "{d4, d8}", meaning: "choose one of the listed dice" },
+8
View File
@@ -151,6 +151,14 @@ export function renderReference(): string {
);
lines.push("handled.");
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) {
lines.push(`### \`role=${role.role}\``);
lines.push("");
+17 -5
View File
@@ -65,14 +65,26 @@ describe("runCheck", () => {
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({
"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");
});
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", () => {
const dir = fixture({
"doc.md": "```yaml role=tag\ntag: [unclosed\n```\n",
@@ -168,16 +180,16 @@ describe("runCheck", () => {
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({
"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 diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R004");
expect(diagnostic).toBeDefined();
expect(diagnostic!.line).toBe(4);
expect(diagnostic!.line).toBe(1);
});
test("V001 flags a CSV that fails to parse", () => {
-2
View File
@@ -33,8 +33,6 @@ export const CommandLinkManager: Component = () => {
command,
sparkTables: comp.data.sparkTables,
variables: stream.variables,
declarations: comp.data.declarations,
tagModifiers: comp.data.tagModifiers,
}).then((result) => {
if (!result.ok) {
setDispatchError(result.error);
+143
View File
@@ -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);
});
});
+21
View File
@@ -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";
@@ -1,4 +1,4 @@
import { roll as rollDice } from "./dice-engine";
import { roll as rollDice } from "./roller";
export interface DiceRollerResult {
formula: string;
@@ -16,19 +16,14 @@ export interface DiceRollerResult {
error?: string;
}
/**
* 骰子引擎 Hook
* 提供高级骰子功能:骰池、修饰符、组合等
*/
/**
* 执行掷骰
* @param formula 骰子公式,支持:
* - 3d6: 标准骰子
* - {d4, d8, 2d6}: 骰池字面量
* - kh2/dl1: 修饰符
* - +: 组合骰池
* - -: 负数组合
* - 5: 固定数字
*
* Supports pools, modifiers and combinations. For example:
* - `3d6`: standard dice
* - `{d4, d8, 2d6}`: pool literal
* - `kh2` / `dl1`: keep-high / drop-low modifiers
* - `+` / `-`: combine pools; a bare number is a fixed value
*/
export function rollFormula(formula: string): DiceRollerResult {
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,
};
}
}
-4
View File
@@ -50,8 +50,6 @@ import mdEmbedRaw from "../doc-entries/md-embed.md";
import mdDeckRaw from "../doc-entries/md-deck.md";
import mdYarnRaw from "../doc-entries/md-yarn-spinner.md";
import mdCommanderRaw from "../doc-entries/md-commander.md";
const rawDocuments: string[] = [
mdDiceRaw,
mdTableRaw,
@@ -63,8 +61,6 @@ const rawDocuments: string[] = [
mdEmbedRaw,
mdDeckRaw,
mdYarnRaw,
mdCommanderRaw,
];
let _entries: DocEntry[] | null = null;
-27
View File
@@ -8,7 +8,6 @@ import "./md-border";
import "./md-font";
import "./md-embed";
import "./md-deck";
import "./md-commander/index";
import "./md-yarn-spinner";
// 导出组件
@@ -33,29 +32,3 @@ export type { FontProps } from "./md-font";
export type { BorderProps } from "./md-border";
export type { EmbedProps } from "./md-embed";
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";
-2
View File
@@ -109,8 +109,6 @@ export const JournalInput: Component = () => {
command: raw,
sparkTables: comp.data.sparkTables,
variables: stream.variables,
declarations: comp.data.declarations,
tagModifiers: comp.data.tagModifiers,
});
// dispatchCommand already set the shared error if it failed.
+18 -1
View File
@@ -17,6 +17,9 @@ export const VariableView: Component = () => {
const values = createMemo(() => stream.variables);
/** Runtime engine diagnostics (bad declarations / modifiers). */
const diagnostics = createMemo(() => comp.diagnostics());
/** Flat list of all variables */
const items = createMemo(() => {
const vars = values();
@@ -32,11 +35,25 @@ export const VariableView: Component = () => {
return (
<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
when={hasAnyData()}
fallback={
<p class="text-center text-gray-400 text-xs py-8">
暂无变量定义。在文档中使用 ```csv role=declare 代码块定义变量。
暂无变量定义。在文档中使用 ```yaml role=declare 代码块定义变量。
</p>
}
>
+19 -15
View File
@@ -11,8 +11,8 @@ import { parseInput } from "./command-parser";
import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
import { evaluateExpression, exprValueToString } from "./variable-expression";
import { computeCascade, getCombined, setBase } from "./var-reactivity";
import type { VarDeclaration, TagModifier } from "./declare-parser";
import { computeCascade, getCombined, resolveVariable, setBase } from "./var-reactivity";
import { ensureReactivity } from "./completions";
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
@@ -58,10 +58,6 @@ export interface DispatchContext {
}[];
/** Current runtime variable values */
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();
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
if (ctx.role === "observer") {
const result = sendMessage("chat", { text: raw });
@@ -131,7 +131,8 @@ export async function dispatchCommand(
if (arg.includes("$")) {
try {
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") {
return finish({ ok: false, error: "Roll expression must evaluate to a number" });
@@ -204,11 +205,8 @@ function dispatchSet(
} else if (p.expr) {
// Evaluate expression — handles both numeric and tagmap values
const result = evaluateExpression(p.expr, {
lookup: (name: string) => {
const k = "$" + name;
// Use getCombined with stream fallback for accurate values
return k === key ? undefined : getCombined(k, ctx.variables);
},
// Strict: referencing an undefined variable is an error, not 0.
lookup: (name) => resolveVariable(name, ctx.variables),
});
newValue = exprValueToString(result.value);
} else {
@@ -236,6 +234,7 @@ function dispatchSet(
action: "set",
key: change.key,
value: change.value,
base: change.base,
});
}
} catch (e) {
@@ -244,10 +243,15 @@ function dispatchSet(
}
// Send the direct set last so cascade messages arrive first.
// Use getCombined with stream fallback so the stream store receives the
// correct value (base + active mods) rather than just the raw base.
// Publish the combined value for display plus the raw base so hydrated
// clients can rebuild the engine exactly (see rebuildReactivityFromStore).
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);
if (!u1.ok) return u1;
+102 -25
View File
@@ -30,8 +30,13 @@ import type {
VarDeclaration,
TagModifier,
} from "../../cli/completions/types";
import { initReactivity, computeInitialValues } from "./var-reactivity";
import { sendMessage, journalStreamState } from "../stores/journalStream";
import { initReactivity, computeInitialValues, rebuildReactivityFromStore, getReactivityDiagnostics, onReactivityDiagnostics, type ReactivityDiagnostic } from "./var-reactivity";
import {
sendMessage,
journalStreamState,
hasHydrated,
setHydrationListener,
} from "../stores/journalStream";
export type { VarDeclaration, TagModifier };
@@ -53,6 +58,14 @@ const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
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
// modes so inline content ids can be resolved at runtime (e.g. spark rolls).
let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} };
@@ -133,22 +146,84 @@ async function scanClientSide(): Promise<JournalCompletions> {
// ------------------- 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
const _initPromise: Promise<void> = (async () => {
// 1. Try server first (CLI mode)
const serverData = await tryServer();
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();
try {
initReactivity({
declarations: serverData.declarations,
tagModifiers: serverData.tagModifiers,
});
seedDeclaredVariables();
} catch (e) {
console.warn("[completions] reactivity init error:", e);
}
setCompletionsState({ status: "loaded", data: serverData });
ensureReactivity();
return;
}
@@ -157,15 +232,7 @@ const _initPromise: Promise<void> = (async () => {
const data = await scanClientSide();
if (data.dice.length > 0 || data.links.length > 0) {
setCompletionsState({ status: "loaded", data });
try {
initReactivity({
declarations: data.declarations,
tagModifiers: data.tagModifiers,
});
seedDeclaredVariables();
} catch (e) {
console.warn("[completions] reactivity init error:", e);
}
ensureReactivity();
} else {
setCompletionsState({ status: "empty" });
}
@@ -194,13 +261,16 @@ export function ensureCompletions(): Promise<void> {
export function useJournalCompletions(): {
state: CompletionsState;
data: JournalCompletions;
/** Reactive accessor for runtime variable-engine diagnostics. */
diagnostics: () => ReactivityDiagnostic[];
} {
const s = completionsState();
if (s.status === "loaded") {
return { state: s, data: s.data };
return { state: s, data: s.data, diagnostics: reactivityDiagnostics };
}
return {
state: s,
diagnostics: reactivityDiagnostics,
data: {
dice: [],
links: [],
@@ -216,8 +286,15 @@ export function useJournalCompletions(): {
// ---------------------------------------------------------------------------
function seedDeclaredVariables(): void {
const initial = computeInitialValues(journalStreamState.variables);
for (const { key, value } of initial) {
sendMessage("var", { action: "set", key, value });
if (!_reactivityReady) return;
// Only seed into a live session. Before connect the store is empty and
// 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 });
}
}
+2 -1
View File
@@ -3,7 +3,8 @@
*/
export {
parseDeclareCsv,
parseDeclare,
declareSchema,
type VarDeclaration,
type TagModifier,
type DeclareResult,
+4 -4
View File
@@ -48,7 +48,7 @@ export { StreamMessageCard } from "./StreamMessage";
export { ComposePanel } from "./ComposePanel";
export { JournalInput } from "./JournalInput";
export { DynamicForm } from "./DynamicForm";
export { useJournalCompletions } from "./completions";
export { useJournalCompletions, ensureReactivity } from "./completions";
export { dispatchCommand } from "./command-dispatcher";
export type { DispatchContext, DispatchResult } from "./command-dispatcher";
export { parseInput } from "./command-parser";
@@ -56,11 +56,11 @@ export type { ParsedInput, CompletionItem } from "./command-parser";
export { buildCompletions } from "./command-completions";
export type { CompletionsContext } from "./command-completions";
export { VariableView } from "./VariableView";
export { parseDeclareCsv } from "./declare-parser";
export { parseDeclare, declareSchema } from "./declare-parser";
export type { VarDeclaration, TagModifier } from "./declare-parser";
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
export type { VarReactivityState, VariableStore } from "./var-reactivity";
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues, getReactivityDiagnostics, clearReactivityDiagnostics, onReactivityDiagnostics, resolveVariable } from "./var-reactivity";
export type { VarReactivityState, VariableStore, ReactivityDiagnostic } from "./var-reactivity";
export { JournalContext, useJournalContext } from "./JournalContext";
export type { JournalContextValue } from "./JournalContext";
+1 -1
View File
@@ -8,7 +8,7 @@
import { z } from "zod";
import { registerMessageType } from "../registry";
import { rollFormula } from "../../md-commander/hooks";
import { rollFormula } from "../../dice";
const schema = z.object({
notation: z.string().min(1),
+54 -32
View File
@@ -15,10 +15,10 @@
import { z } from "zod";
import { For } from "solid-js";
import { registerMessageType } from "../registry";
import { rollFormula } from "../../md-commander/hooks";
import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table";
import { getIndexedData } from "../../../data-loader/file-index";
import { getRegistry } from "../../journal/completions";
import { parseMarkdown } from "../../../markdown";
// ---------------------------------------------------------------------------
// Schema
@@ -28,12 +28,29 @@ const sparkColumnSchema = z.object({
header: z.string(),
slug: z.string(),
value: z.string(),
/** Dice total that selected this column's row. */
roll: z.number().optional(),
});
const sparkResultSchema = z.object({
notation: z.string(),
columns: z.array(sparkColumnSchema),
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({
@@ -43,18 +60,11 @@ const schema = z.object({
notation: z.string().min(1),
/** Resolved spark table columns */
sparkTable: sparkResultSchema,
/** First column's raw dice roll — informational only */
rollResult: z.object({
total: z.number(),
detail: z.string(),
plainDetail: z.string(),
pools: z.array(
z.object({
rolls: z.array(z.number()),
subtotal: z.number(),
}),
),
}),
/**
* Dice result for the shared roll — absent in remix mode, where each column
* rolls independently and carries its own `roll` instead.
*/
rollResult: rollDetailSchema.optional(),
});
export type SparkPayload = z.infer<typeof schema>;
@@ -105,13 +115,13 @@ export async function resolveSparkPayload(raw: {
}
const sparkResult = rollSparkTable(meta, { remix: raw.remix });
const firstRoll = rollFormula(meta.notation);
return {
label: raw.key,
notation: meta.notation,
sparkTable: sparkResult,
rollResult: firstRoll.result,
// Source path lets markdown in the rolled values resolve relative refs.
sparkTable: { ...sparkResult, source: raw.docPath ?? "" },
rollResult: sparkResult.roll,
};
}
@@ -127,35 +137,47 @@ registerMessageType<SparkPayload>({
defaultPayload: () => ({
label: "",
notation: "d6",
sparkTable: { notation: "d6", columns: [], source: "" },
rollResult: { total: 0, detail: "", plainDetail: "", pools: [] },
sparkTable: { notation: "d6", columns: [], source: "", remix: false },
}),
render: (p) => {
const st = p.sparkTable;
const sharedRoll = p.rollResult?.total;
return (
<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">
<table class="w-full text-xs">
<tbody>
<For each={st.columns}>
{(col) => (
<tr class="border-t border-purple-100 first:border-t-0">
<td class="px-2 py-1 text-purple-600 font-medium w-1/3">
{col.header}
</td>
<td class="px-2 py-1 text-gray-800">{col.value}</td>
</tr>
)}
{(col) => {
const roll = col.roll ?? sharedRoll;
return (
<tr class="border-t border-purple-100 first:border-t-0">
<td class="px-2 py-1 text-purple-600 font-medium whitespace-nowrap">
{col.header}
{roll != null && (
<span class="font-mono text-purple-400">
{" / "}
{st.notation}
{" -> "}
{roll}
</span>
)}
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
<For each={st.columns}>
{(col) => (
<div
class="px-2 py-1 [&_p]:m-0"
innerHTML={parseMarkdown(col.value, st.source)}
/>
)}
</For>
</div>
);
},
+14 -17
View File
@@ -4,9 +4,9 @@
* Emitters: gm, player
* Command: /set $key expression
*
* Variable declarations and tag modifiers are authored in ```csv role=declare
* code blocks in markdown documents. The stream carries set/del mutations
* that build up the runtime variable store additively.
* Variable declarations and tag modifiers are authored in ```yaml role=declare
* code blocks in markdown documents. The stream carries `set` mutations that
* build up the runtime variable store additively.
*/
import { z } from "zod";
@@ -15,9 +15,13 @@ import { journalSetState } from "../../stores/journalStream";
import { produce } from "solid-js/store";
const schema = z.object({
action: z.enum(["set", "del"]),
action: z.literal("set"),
key: z.string().min(1),
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>;
@@ -29,16 +33,6 @@ registerMessageType<VarPayload>({
schema,
defaultPayload: () => ({ action: "set", key: "", value: "" }),
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 (
<div class="flex items-center gap-1.5">
<span class="text-lg">🔢</span>
@@ -51,10 +45,13 @@ registerMessageType<VarPayload>({
reducer: (p) => {
journalSetState(
produce((s) => {
if (p.action === "del") {
delete s.variables[p.key];
} else if (p.value !== undefined) {
if (p.value !== undefined) {
s.variables[p.key] = p.value;
if (p.base !== undefined) {
s.variableBases[p.key] = p.base;
} else {
delete s.variableBases[p.key];
}
}
}),
);
+230 -63
View File
@@ -35,6 +35,18 @@ export interface VarReactivityState {
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. */
export interface ActiveMod {
tag: string; // "#warrior"
@@ -44,6 +56,26 @@ export interface ActiveMod {
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
// ---------------------------------------------------------------------------
@@ -121,6 +153,61 @@ const sourceActivations = new Map<string, Array<{
/** Set of $vars currently being re-evaluated (cycle guard) */
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
// ---------------------------------------------------------------------------
@@ -129,42 +216,64 @@ const inFlight = new Set<string>();
* Initialize (or re-initialize) the reactivity engine from declarations
* 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.
*/
export function initReactivity(state: VarReactivityState): void {
depGraph = new Map();
declExprs = new Map();
tagModMap = new Map();
baseValues.clear();
numericMods.clear();
tagMapMods.clear();
sourceActivations.clear();
const nextTagModMap = new Map<
string,
Array<{ target: string; expression: string; threshold: number }>
>();
const nextDeclExprs = new Map<string, string>();
const nextDepGraph = new Map<string, Set<string>>();
// Index tag modifiers
for (const tm of state.tagModifiers) {
let list = tagModMap.get(tm.tag);
let list = nextTagModMap.get(tm.tag);
if (!list) {
list = [];
tagModMap.set(tm.tag, list);
nextTagModMap.set(tm.tag, list);
}
list.push({ target: tm.target, expression: tm.expression, threshold: tm.threshold });
}
// Index declarations and build dependency graph
for (const decl of state.declarations) {
declExprs.set(decl.key, decl.expression);
nextDeclExprs.set(decl.key, decl.expression);
const deps = extractDependencies(decl.expression);
for (const dep of deps) {
let dependents = depGraph.get(dep);
let dependents = nextDepGraph.get(dep);
if (!dependents) {
dependents = new Set();
depGraph.set(dep, dependents);
nextDepGraph.set(dep, dependents);
}
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. */
@@ -222,6 +331,24 @@ export function getCombined(key: string, fallback?: VariableStore): string {
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).
* 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
* so that tag activations are correctly tracked for subsequent cascade
* 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;
for (const [key, value] of Object.entries(variables)) {
if (!baseValues.has(key)) {
baseValues.set(key, value);
}
// Reset, then restore exact bases from the store's base map.
baseValues.clear();
numericMods.clear();
tagMapMods.clear();
sourceActivations.clear();
const tagMap = isTagMapValue(value) ? parseTagMap(value) : null;
if (tagMap && !sourceActivations.has(key)) {
// Activate all tags in the tagmap
const added = applyTagMapActivations(key, {}, tagMap, variables);
// We don't return the added entries here — they'll be applied
// to the store separately
}
for (const [key, value] of Object.entries(variables)) {
baseValues.set(key, bases?.[key] ?? value);
}
// Re-attach tag modifiers from each source's *base* tagmap. Using the
// 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,
oldValue: string | undefined,
currentVars: VariableStore,
): Array<{ key: string; value: string }> {
): VarChange[] {
if (!depGraph || !declExprs || !tagModMap) {
return [];
}
const results: Array<{ key: string; value: string }> = [];
const results: VarChange[] = [];
// ---- Tag activation/deactivation ----
const newValue = getCombined(changedVar, currentVars);
@@ -325,28 +464,35 @@ export function computeCascade(
/**
* 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(
currentVars: VariableStore,
): Array<{ key: string; value: string }> {
existing?: ReadonlySet<string>,
): VarChange[] {
if (!declExprs) return [];
const allKeys = [...declExprs.keys()];
const sorted = topoSortAffected(new Set(allKeys));
const results: Array<{ key: string; value: string }> = [];
const results: VarChange[] = [];
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);
if (!expr) continue;
try {
const result = evaluateExpression(expr, {
lookup: (name: string) => {
const k = "$" + name;
return getCombined(k, currentVars);
},
// Declarations may reference runtime/external variables; getCombined
// defaults unknown keys to "0" rather than erroring.
lookup: (name) => getCombined(name, currentVars),
});
const rawValue = exprValueToString(result.value);
@@ -366,10 +512,15 @@ export function computeInitialValues(
// Always emit the combined value for this key
const combined = getCombined(key, currentVars);
if (!results.some((x) => x.key === key)) {
results.push({ key, value: combined });
results.push({ key, value: combined, base: rawValue });
}
} catch {
// skip failed evaluations at init time
} catch (e) {
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>,
newTagMap: Record<string, number>,
fallback: VariableStore,
): Array<{ key: string; value: string }> {
): VarChange[] {
if (!tagModMap) return [];
const results: Array<{ key: string; value: string }> = [];
const results: VarChange[] = [];
const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]);
for (const tag of allTags) {
@@ -416,10 +567,8 @@ function applyTagMapActivations(
}
const evalResult = evaluateExpression(mod.expression, {
lookup: (name: string) => {
const k = "$" + name;
return getCombined(k, fallback);
},
// Modifier expressions tolerate undefined references (→ "0").
lookup: (name) => getCombined(name, fallback),
});
// Modifier expressions must evaluate to a number
@@ -467,14 +616,24 @@ function applyTagMapActivations(
// Emit new combined value
const combined = getCombined(mod.target, fallback);
const base = baseValues.get(mod.target) ?? combined;
const existing = results.findIndex((r) => r.key === mod.target);
if (existing >= 0) {
results[existing] = { key: mod.target, value: combined };
results[existing] = { key: mod.target, value: combined, base };
} else {
results.push({ key: mod.target, value: combined });
results.push({ key: mod.target, value: combined, base });
}
} catch {
// skip failed modifier
} catch (e) {
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) {
// Deactivate this modifier
@@ -512,11 +671,12 @@ function applyTagMapActivations(
// Emit updated combined value
const combined = getCombined(entry.target, fallback);
const base = baseValues.get(entry.target) ?? combined;
const existing = results.findIndex((r) => r.key === entry.target);
if (existing >= 0) {
results[existing] = { key: entry.target, value: combined };
results[existing] = { key: entry.target, value: combined, base };
} else {
results.push({ key: entry.target, value: combined });
results.push({ key: entry.target, value: combined, base });
}
}
}
@@ -537,7 +697,7 @@ function applyTagMapActivations(
function reevaluateDependents(
changedVar: string,
fallback: VariableStore,
): Array<{ key: string; value: string }> {
): VarChange[] {
if (!depGraph || !declExprs) return [];
// Collect all dependents reachable from changedVar (BFS)
@@ -556,7 +716,7 @@ function reevaluateDependents(
}
const sorted = topoSortAffected(affected);
const results: Array<{ key: string; value: string }> = [];
const results: VarChange[] = [];
for (const key of sorted) {
if (inFlight.has(key)) {
@@ -571,10 +731,8 @@ function reevaluateDependents(
if (!expr) continue;
const result = evaluateExpression(expr, {
lookup: (name: string) => {
const k = "$" + name;
return getCombined(k, fallback);
},
// Declaration re-evaluation stays lenient for undeclared references.
lookup: (name) => getCombined(name, fallback),
});
const rawValue = exprValueToString(result.value);
@@ -599,8 +757,16 @@ function reevaluateDependents(
// Emit combined value
const combined = getCombined(key, fallback);
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 {
inFlight.delete(key);
}
@@ -631,7 +797,10 @@ function topoSortAffected(affected: Set<string>): string[] {
if (expr) {
const deps = extractDependencies(expr);
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);
}
}
@@ -649,10 +818,8 @@ function topoSortAffected(affected: Set<string>): string[] {
return result;
}
function checkCircular(): void {
if (!declExprs) return;
const allKeys = [...declExprs.keys()];
function checkCircular(exprs: Map<string, string>): void {
const allKeys = [...exprs.keys()];
const state = new Map<string, "unvisited" | "visiting" | "visited">();
for (const k of allKeys) state.set(k, "unvisited");
@@ -672,11 +839,11 @@ function checkCircular(): void {
state.set(key, "visiting");
path.push(key);
const expr = declExprs!.get(key);
const expr = exprs.get(key);
if (expr) {
const deps = extractDependencies(expr);
for (const dep of deps) {
if (declExprs!.has(dep)) {
if (exprs.has(dep)) {
dfs(dep);
}
}
+38 -6
View File
@@ -6,7 +6,7 @@
* - Number literals (integer or decimal)
* - Tagmap literals: #warrior:1;#druid:2 (bare #warrior → #warrior:1)
* - $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)
* - Functions: floor(x), ceil(x), round(x) (numbers only)
* - Parentheses for grouping
@@ -16,10 +16,12 @@
* - Circular variable references (detected by caller)
* - Division by zero
* - Unknown functions
* - Unknown variables (lookup returned undefined)
* - Malformed expressions
* - Malformed numeric literals (e.g. "1.2.3")
*/
import { rollFormula } from "../md-commander/hooks";
import { rollFormula } from "../dice";
// ---------------------------------------------------------------------------
// Types
@@ -31,8 +33,13 @@ export type ExprValue =
| { kind: "tagmap"; value: Record<string, number> };
export interface EvalContext {
/** Resolve $var → string (numeric or tagmap serialized form).
* Return undefined if the variable doesn't exist. */
/** Resolve a variable reference — the full name including the leading `$`
* (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;
}
@@ -168,6 +175,9 @@ interface Token {
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
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" */
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 });
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 });
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
if (ch === "#") {
let raw = "";
@@ -411,9 +443,9 @@ function parseFactor(
// Variable reference: $var
if (tok.kind === "var") {
const varName = tok.value; // includes $ prefix
const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup
const resolved = ctx.lookup(varName);
if (resolved === undefined) {
return { value: { kind: "number", value: 0 }, next: pos + 1 };
throw new Error(`Unknown variable: ${varName}`);
}
// Auto-detect: tagmap or numeric
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>
);
};
-34
View File
@@ -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>
);
};
-169
View File
@@ -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,
};
}
-161
View File
@@ -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", []);
}
-117
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
import { customElement, noShadowDOM } from "solid-element";
import { createSignal, onMount } from "solid-js";
import { rollFormula } from "./md-commander/hooks";
import { rollFormula } from "./dice";
export interface DiceProps {
key?: string;
+28 -1
View File
@@ -46,6 +46,8 @@ export interface JournalStreamState {
brokerUrl: string | null;
players: Record<string, { role: string }>;
variables: Record<string, string>;
/** Raw base values (before tag modifiers), keyed the same as `variables`. */
variableBases: Record<string, string>;
}
export interface SessionMeta {
@@ -98,6 +100,7 @@ const [state, setState] = createStore<JournalStreamState>({
brokerUrl: persisted.brokerUrl,
players: {},
variables: {},
variableBases: {},
});
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
@@ -128,11 +131,33 @@ function replayReducers(): void {
// Reset derived state
setState("revealedPaths", {});
setState("variables", {});
setState("variableBases", {});
for (const msg of state.messages) {
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
// ---------------------------------------------------------------------------
@@ -165,7 +190,9 @@ function handlePatch(patch: WorkerPatch): void {
replayReducers();
// Rebuild local reactivity engine state so tag activations from
// hydrated variables are tracked for subsequent cascade computations.
rebuildReactivityFromStore(state.variables);
rebuildReactivityFromStore(state.variables, state.variableBases);
_hydrated = true;
_hydrationListener?.();
break;
}
+57
View File
@@ -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"]);
}
});
});
+35 -21
View File
@@ -15,7 +15,7 @@
import Slugger from "github-slugger";
import { parse } from "csv-parse/browser/esm/sync";
import { parseCSVString } from "../utils/csv-loader";
import { rollFormula } from "../md-commander/hooks";
import { rollFormula } from "../dice";
// ---------------------------------------------------------------------------
// Types
@@ -25,8 +25,13 @@ export interface SparkTableColumn {
header: string;
slug: 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 {
/** The dice notation (e.g. "d6", "d20") */
notation: string;
@@ -34,6 +39,10 @@ export interface SparkTableResult {
columns: SparkTableColumn[];
/** Source file path */
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 {
@@ -157,8 +166,9 @@ export function rollSparkTable(
): SparkTableResult {
const slugger = new Slugger();
const columns: SparkTableColumn[] = [];
const remix = options.remix ?? false;
if (options.remix) {
if (remix) {
// Independent roll per column
for (const header of meta.dataHeaders) {
const slug = slugger.slug(header.toLowerCase());
@@ -173,33 +183,37 @@ export function rollSparkTable(
}
}
columns.push({ header, slug, value });
}
} 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;
}
columns.push({ header, slug, value, roll: rolledValue });
}
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 });
return { notation: meta.notation, columns, source: "", remix };
}
// 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) {
const slug = slugger.slug(header.toLowerCase());
const value = matchedRow
? (matchedRow[header] ?? "")
: `(no row for ${rolledValue})`;
columns.push({ header, slug, value, roll: rolledValue });
}
return {
notation: meta.notation,
columns,
source: "",
remix,
roll: roll.result,
};
}
+1 -1
View File
@@ -8,7 +8,7 @@
* Dev 工作流:运行 CLI 服务器并在 rsbuild 配置中设置代理
* > npm run tttk serve ./content
* > npm run dev
* rsbuild 会将 /__CONTENT_INDEX.json、/__COMPLETIONS.json、/content/ 代理到 CLI 服务器
* rsbuild 会将 /__CONTENT_INDEX.json、/__COMPLETIONS.json、/__CONTENT_REGISTRY.json、/content/ 代理到 CLI 服务器
*/
import {
+53 -35
View File
@@ -3,11 +3,11 @@ tag: journal-set
icon: 🔢
title: 变量系统
description: 在文档中声明变量和标签,通过 /set 命令设置变量值,自动计算派生变量和标签效果。
syntax: '```csv role=declare'
syntax: '```yaml role=declare'
props:
- name: 定义文件
type: —
desc: 在任意 .md 文档中使用 ```csv role=declare 代码块定义变量和标签
desc: 在任意 .md 文档中使用 ```yaml role=declare 代码块定义变量和标签
- name: 命令
type: —
desc: /set $key expression | /set $key #tag1:count1;#tag2:count2
@@ -28,37 +28,48 @@ props:
## 代码块语法
```csv role=declare
tag,threshold,key,expr
,,$hp,$con*5+$mod_hp
,,$ac,10+$dex
#warrior,1,$mod_hp,20
#warrior,2,$mod_str,1
以 `yaml role=declare` 代码块定义,正文为 YAML:
```yaml role=declare
vars:
$hp: $con*5 + $mod_hp
$ac: 10 + $dex
tags:
warrior:
1:
$mod_hp: 20
2:
$mod_str: 1
```
| 列 | 说明 |
| 键 | 说明 |
|---|---|
| `tag` | 标签名(空表示普通变量声明) |
| `threshold` | 激活阈值(可选,默认 `1`)。标签计数值 ≥ 阈值时激活 |
| `key` | 变量名,以 `$` 开头 |
| `expr` | 表达式,支持数字、`$var` 引用、骰子、算术、函数 |
| `vars` | 变量声明映射:`$name: 表达式` |
| `tags` | 标签修饰符映射:`标签名 → 阈值 → {$目标: 增量}` |
标签名可以带或不带 `#` 前缀。阈值是正整数:标签计数值达到该值时,修饰符生效。
数字会被当作数值,其余一律作为表达式文本(如 `$con*5+$mod_hp`)。
### 声明类型
**普通声明**(tag 为空):`$key` 是一个派生变量,其值由表达式计算。当依赖变量变化时自动重新计算。
**普通声明**(`vars`):`$key` 是一个派生变量,其值由表达式计算。当依赖变量变化时自动重新计算。
```csv role=declare
tag,key,expr
,$hp,$con*5+$mod_hp
,$ac,10+$dex
```yaml role=declare
vars:
$hp: $con*5 + $mod_hp
$ac: 10 + $dex
```
**标签修饰符**(tag 非空):当源变量的标签映射中 `#tag` 的计数值 ≥ `threshold` 时,`$key` 加上表达式的计算结果。修饰符按阈值独立激活。
**标签修饰符**(`tags`):当源变量的标签映射中 `#tag` 的计数值 ≥ 阈值时,`$key` 加上该值。同一标签的多个阈值会叠加,修饰符按阈值独立激活。
```csv role=declare
tag,threshold,key,expr
#warrior,1,$mod_hp,20
#warrior,2,$mod_str,1
```yaml role=declare
tags:
warrior:
1:
$mod_hp: 20
2:
$mod_str: 1
```
上例中:
@@ -141,10 +152,13 @@ tag,threshold,key,expr
阈值允许同一标签产生不同层级的效果:
```csv role=declare
tag,threshold,key,expr
#warrior,1,$mod_hp,10
#warrior,3,$mod_hp,20
```yaml role=declare
tags:
warrior:
1:
$mod_hp: 10
3:
$mod_hp: 20
```
- `#warrior` 计数 1-2:`$mod_hp` +10
@@ -174,14 +188,18 @@ tag,threshold,key,expr
在文档中定义:
```csv role=declare
tag,threshold,key,expr
,,$con,10
,,$dex,12
,,$hp,$con*5+$mod_hp
,,$ac,10+$dex
#warrior,1,$mod_hp,20
#warrior,2,$mod_str,1
```yaml role=declare
vars:
$con: 10
$dex: 12
$hp: $con*5 + $mod_hp
$ac: 10 + $dex
tags:
warrior:
1:
$mod_hp: 20
2:
$mod_str: 1
```
在 Journal 中输入:
-57
View File
@@ -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、状态等游戏信息。
+8 -2
View File
@@ -14,10 +14,16 @@ export interface BlockAttrs {
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 {
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;
while ((m = re.exec(info)) !== null) {
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
+10
View File
@@ -70,6 +70,16 @@ describe("code-block-yaml-tag", () => {
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", () => {
const token = ext.tokenizer("```yaml tag=md-deck\nsize: 54x86\n```");
expect(token).toBeUndefined();
+5 -5
View File
@@ -13,7 +13,7 @@ import { parseBlockAttrs } from "./block-attrs.js";
* `role=tag` on the info string marks the block (and lets the content
* scanner keep it intact). `tag=` / `id=` / any other `key=value` fence
* 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 {
return {
@@ -22,13 +22,13 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
name: "code-block-yaml-tag",
level: "block",
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) {
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);
if (match) {
const yamlContent = match[2]?.trim() || "";
const yamlContent = match[3]?.trim() || "";
let yamlProps: Record<string, unknown> = {};
try {
yamlProps =
@@ -39,7 +39,7 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
}
// Fence attributes override YAML body values.
const attrs = parseBlockAttrs(match[1] || "");
const attrs = parseBlockAttrs(match[2] || "");
const fenceProps: Record<string, unknown> = {
...(attrs.id ? { id: attrs.id } : {}),
...attrs.extra,
+4
View File
@@ -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"]`
## variable store(./src/components/journal)
- [ ] fix the outstanding issues from the reactivity audit: [`docs/variable-store-plan.md`](./docs/variable-store-plan.md)