Compare commits
42
Commits
d8ccaecade
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae36125af0 | ||
|
|
2ba6ed74c7 | ||
|
|
8957e3d2f1 | ||
|
|
1ce76dca64 | ||
|
|
80da5364e5 | ||
|
|
4a19336445 | ||
|
|
bd7871a800 | ||
|
|
c81cf221ef | ||
|
|
e0e7250180 | ||
|
|
12f8d8542c | ||
|
|
49bed7d279 | ||
|
|
40b6fbe157 | ||
|
|
b5f25e6b60 | ||
|
|
be247cd48c | ||
|
|
c3cdc724bd | ||
|
|
421af125bb | ||
|
|
a65ca2ab93 | ||
|
|
68b976575a | ||
|
|
30ec8a43cf | ||
|
|
59f87bffda | ||
|
|
f9ea37427b | ||
|
|
ebb49ddfca | ||
|
|
322287da29 | ||
|
|
08bc97bec5 | ||
|
|
264038e27c | ||
|
|
e838f9a4b1 | ||
|
|
3532be2335 | ||
|
|
f17f09a8c4 | ||
|
|
3508286792 | ||
|
|
0b029b325e | ||
|
|
0ac9a833e4 | ||
|
|
fadfd4cba6 | ||
|
|
16f2038fd8 | ||
|
|
02442a1100 | ||
|
|
9f372fa2b0 | ||
|
|
a26d53cbeb | ||
|
|
0ce91afc49 | ||
|
|
c7f3b3425b | ||
|
|
b5591f2a78 | ||
|
|
2ed69d744a | ||
|
|
53620e0a92 | ||
|
|
a752fdc2f4 |
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
name: git-commits
|
||||||
|
description: Write Git commit messages for this project using Conventional Commits, splitting work into one commit per concern. Use when staging changes, writing or amending commit messages, or deciding how to break a set of changes into commits.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Git Commits
|
||||||
|
|
||||||
|
Use this skill whenever you write a commit message or decide how to group changes into commits.
|
||||||
|
|
||||||
|
## Commit message style: Conventional Commits
|
||||||
|
|
||||||
|
Every commit message must follow the Conventional Commits format:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<optional scope>): <description>
|
||||||
|
|
||||||
|
<optional body>
|
||||||
|
|
||||||
|
<optional footer(s)>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
- `feat`: a new feature
|
||||||
|
- `fix`: a bug fix
|
||||||
|
- `docs`: documentation only
|
||||||
|
- `style`: formatting, whitespace, or other changes that do not affect behavior
|
||||||
|
- `refactor`: a code change that neither fixes a bug nor adds a feature
|
||||||
|
- `perf`: a performance improvement
|
||||||
|
- `test`: adding or correcting tests
|
||||||
|
- `build`: changes to the build system or dependencies
|
||||||
|
- `ci`: changes to CI configuration and scripts
|
||||||
|
- `chore`: other changes that do not modify source or tests
|
||||||
|
- `revert`: revert a previous commit
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- Use the imperative mood in the description ("add", not "added" or "adds").
|
||||||
|
- Keep the description lowercase and do not end it with a period.
|
||||||
|
- Limit the subject line to 50 characters where practical; never exceed 72.
|
||||||
|
- Add a scope in parentheses when it clarifies the area of change, e.g. `fix(parser): ...`.
|
||||||
|
- Separate the subject from the body with a blank line.
|
||||||
|
- Use the body only when it adds useful information; do not restate the subject.
|
||||||
|
- Wrap the body at 72 characters.
|
||||||
|
- Reference issues in the footer when relevant, e.g. `Closes #123`.
|
||||||
|
- Mark breaking changes with `!` after the type/scope (e.g. `feat(api)!: ...`) and explain them in a `BREAKING CHANGE:` footer.
|
||||||
|
|
||||||
|
## Separate commits by concern
|
||||||
|
|
||||||
|
Do not bundle unrelated changes into a single commit. Split work so that each commit represents one logical concern:
|
||||||
|
|
||||||
|
- One type of change per commit (a feature and its unrelated refactor are separate commits).
|
||||||
|
- Keep formatting-only changes separate from behavior changes.
|
||||||
|
- Keep test additions separate from the implementation they cover when they are independently meaningful, but do not split so finely that a commit is not self-contained.
|
||||||
|
- Each commit should build and pass tests on its own where feasible.
|
||||||
|
- Stage files selectively (e.g. `git add -p`, `git add <path>`) to isolate concerns rather than committing everything at once.
|
||||||
|
|
||||||
|
When a set of changes spans multiple concerns, propose the split to the user before committing.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Inspect what changed with `git --no-optional-locks status` and `git --no-pager diff`.
|
||||||
|
2. Group the changes by concern.
|
||||||
|
3. For each group, stage only the relevant files or hunks.
|
||||||
|
4. Write a Conventional Commits message for that group.
|
||||||
|
5. Repeat until all changes are committed.
|
||||||
|
|
||||||
|
Do not commit or create branches unless the user explicitly asks.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: tttk-authoring
|
name: tttk-authoring
|
||||||
description: Author and edit TTRPG game design documents for the tttk (Tabletop Toolkit) CLI. Use when writing or modifying markdown content that uses tttk custom elements (:md-dice, :md-table, :md-deck, :md-pins, :md-commander, etc.), custom fenced code blocks (role=spark-table / declare / file / tag), companion CSV data files, or when previewing content with `tttk serve` and validating it with `tttk check`.
|
description: Author and edit TTRPG game design documents for the tttk (Tabletop Toolkit) CLI. Use when writing or modifying markdown content that uses tttk custom elements (:md-dice, :md-table, :md-deck, :md-print, :md-pins, etc.), custom fenced code blocks (role=spark-table / declare / file / tag), companion CSV data files, or when previewing content with `tttk serve` and validating it with `tttk check`.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Authoring tttk game design documents
|
# Authoring tttk game design documents
|
||||||
@@ -54,13 +54,15 @@ in `reference.md`.
|
|||||||
|
|
||||||
## Code block roles
|
## Code block roles
|
||||||
|
|
||||||
Fenced blocks are dispatched by the `role=` attribute in the info string:
|
Fenced blocks are dispatched by the `role=` attribute in the info string. Both
|
||||||
|
backtick and tilde (`~~~`) fences work; the closing fence must reuse the opening
|
||||||
|
marker, and fence attribute keys may contain hyphens (e.g. `data-foo=bar`):
|
||||||
|
|
||||||
| Info string | Behavior |
|
| Info string | Behavior |
|
||||||
|---|---|
|
|---|---|
|
||||||
| ` ```csv role=spark-table ` | Body is CSV; replaced with `:md-table[./csv_<hash>]`. First column header must be a dice formula (`d6`, `2d6+1`, …). |
|
| ` ```csv role=spark-table ` | Body is CSV; replaced with `:md-table[./csv_<hash>]`. First column header must be a dice formula (`d6`, `2d6+1`, …). |
|
||||||
| ` ```markdown role=spark-table ` | Body is a GFM pipe table; converted to CSV, then same as above. |
|
| ` ```markdown role=spark-table ` | Body is a GFM pipe table; converted to CSV, then same as above. |
|
||||||
| ` ```csv role=declare ` | Defines reactive variables / tag modifiers. Block is stripped. |
|
| ` ```yaml role=declare ` | YAML `vars:` / `tags:` maps defining reactive variables and tag modifiers. Block is stripped. |
|
||||||
| ` ```<lang> role=file ` | Body stored as text content, block stripped, referenced by its derived id. |
|
| ` ```<lang> role=file ` | Body stored as text content, block stripped, referenced by its derived id. |
|
||||||
| ` ```yaml role=tag ` | Kept intact; rendered as a custom element from YAML (`tag:`, `body:`, …). |
|
| ` ```yaml role=tag ` | Kept intact; rendered as a custom element from YAML (`tag:`, `body:`, …). |
|
||||||
| ` ```<lang> ` (no role) | Rendered as a normal visible code block. |
|
| ` ```<lang> ` (no role) | Rendered as a normal visible code block. |
|
||||||
@@ -80,6 +82,10 @@ Fenced blocks are dispatched by the `role=` attribute in the info string:
|
|||||||
- For card decks, use the `yaml role=tag` form with `tag: md-deck` (see
|
- For card decks, use the `yaml role=tag` form with `tag: md-deck` (see
|
||||||
`yaml-tag.md`). The `:md-deck[...]{...}` directive is legacy and can't express
|
`yaml-tag.md`). The `:md-deck[...]{...}` directive is legacy and can't express
|
||||||
template layers or card backs.
|
template layers or card backs.
|
||||||
|
- To print a rules document, use `:md-print[./rules.md]` (or the `yaml role=tag`
|
||||||
|
form). It paginates into A4 landscape sheets holding two A5 pages, each with
|
||||||
|
`columns` columns (default 2). See `reference.md` for the attributes and
|
||||||
|
`rules.md` for how to write the rules text itself.
|
||||||
- Keep CSV files clean — header row plus data. Put deck config and templates in
|
- Keep CSV files clean — header row plus data. Put deck config and templates in
|
||||||
the YAML block's `data-config`, not in CSV front matter (see `csv.md`).
|
the YAML block's `data-config`, not in CSV front matter (see `csv.md`).
|
||||||
- `{{prop}}` in a CSV `body` interpolates another column (or front-matter key)
|
- `{{prop}}` in a CSV `body` interpolates another column (or front-matter key)
|
||||||
@@ -109,7 +115,7 @@ Warnings are usually real too:
|
|||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `TTTK-R002` | spark-table first header is not a dice formula |
|
| `TTTK-R002` | spark-table first header is not a dice formula |
|
||||||
| `TTTK-R004` | `role=declare` row skipped (empty `key` or `expr`) |
|
| `TTTK-R004` | `role=declare` block declares nothing |
|
||||||
| `TTTK-C002` | reference resolves to an unexpected file type |
|
| `TTTK-C002` | reference resolves to an unexpected file type |
|
||||||
| `TTTK-L002` | `#section` matches no heading |
|
| `TTTK-L002` | `#section` matches no heading |
|
||||||
| `TTTK-A001` | icon missing under `assets/` |
|
| `TTTK-A001` | icon missing under `assets/` |
|
||||||
@@ -133,6 +139,9 @@ directory. The full code catalog lives in `reference.md`.
|
|||||||
- `csv.md` — CSV authoring: columns, `{{prop}}` interpolation, quoting, and the
|
- `csv.md` — CSV authoring: columns, `{{prop}}` interpolation, quoting, and the
|
||||||
discouraged front-matter form.
|
discouraged front-matter form.
|
||||||
- `layout.md` — column layout markers.
|
- `layout.md` — column layout markers.
|
||||||
|
- `rules.md` — conventions for writing rules documents: the five-part
|
||||||
|
structure, the theme/players/time header, icon-first mentions, veteran-first
|
||||||
|
prose with details boxed in alerts, and a timestamp.
|
||||||
- `markdown.md` — GitHub alerts and mermaid diagrams.
|
- `markdown.md` — GitHub alerts and mermaid diagrams.
|
||||||
- `examples/adventure.md` and `examples/encounters.csv` — a complete minimal
|
- `examples/adventure.md` and `examples/encounters.csv` — a complete minimal
|
||||||
document and its data file.
|
document and its data file.
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ Nest with more colons (`::::`, `:::::`).
|
|||||||
| `:md-dice` | dice formula | `key` |
|
| `:md-dice` | dice formula | `key` |
|
||||||
| `:md-table` | CSV path or inline id | `roll`, `remix` |
|
| `:md-table` | CSV path or inline id | `roll`, `remix` |
|
||||||
| `:md-deck` | CSV path | `size`, `grid`, `layers` |
|
| `:md-deck` | CSV path | `size`, `grid`, `layers` |
|
||||||
|
| `:md-print` | markdown path, optional #section | `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `timestamp`, `cutMarks` |
|
||||||
| `:md-link` | path, optional #section | — |
|
| `:md-link` | path, optional #section | — |
|
||||||
| `:md-embed` | path, optional #section | `headingBase` |
|
| `:md-embed` | path, optional #section | `headingBase` |
|
||||||
| `:md-bg` | image path or CSS color | `fit` |
|
| `:md-bg` | image path or CSS color | `fit` |
|
||||||
| `:md-border` | color or border image | `width`, `slice` |
|
| `:md-border` | color or border image | `width`, `slice` |
|
||||||
| `:md-font` | font family | `source`, `weight`, `color` |
|
| `:md-font` | font family | `source`, `weight`, `color` |
|
||||||
| `:md-pins` | image path | `pins`, `fixed`, `labelStart` |
|
| `:md-pins` | image path | `pins`, `fixed`, `labelStart` |
|
||||||
| `:md-commander` | command template CSV | `placeholder`, `class`, `height` |
|
|
||||||
| `:md-yarn-spinner` | Yarn Spinner file | `start` |
|
| `:md-yarn-spinner` | Yarn Spinner file | `start` |
|
||||||
|
|
||||||
### `:md-dice`
|
### `:md-dice`
|
||||||
@@ -112,6 +112,36 @@ Prefer the `yaml role=tag` form — it is the only form that supports template l
|
|||||||
:md-deck[./cards.csv]{size="63x88" grid="5x5" layers="title:1,1-5,1f8 body:1,5-5,8f3"}
|
:md-deck[./cards.csv]{size="63x88" grid="5x5" layers="title:1,1-5,1f8 body:1,5-5,8f3"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `:md-print`
|
||||||
|
|
||||||
|
Paginates a markdown document into A4 landscape sheets for printing.
|
||||||
|
|
||||||
|
Each sheet holds two A5 pages side by side, and each A5 page flows the markdown into `columns` columns. `margin`, `gap`, and `font` are in millimetres (the body font defaults to 3mm). `pageNumbers` adds an `n / total` footer in the outer corner, `timestamp` appends the export time to that footer, and `cutMarks` draws the centre cut line. The preview modal exports through the browser's print dialog (choose A4 landscape, no margins, 100% scale).
|
||||||
|
|
||||||
|
- Body: markdown path, optional #section
|
||||||
|
- Attributes: `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `timestamp`, `cutMarks`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
:md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
:md-print[./rules.md]{timestamp=true}
|
||||||
|
```
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```yaml role=tag
|
||||||
|
tag: md-print
|
||||||
|
body: ./rules.md
|
||||||
|
columns: 2
|
||||||
|
margin: 10
|
||||||
|
gap: 5
|
||||||
|
font: 3
|
||||||
|
title: 规则书
|
||||||
|
timestamp: true
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
### `:md-link`
|
### `:md-link`
|
||||||
|
|
||||||
Clickable link that expands the target article inline.
|
Clickable link that expands the target article inline.
|
||||||
@@ -205,19 +235,6 @@ Numbered markers on an image.
|
|||||||
:md-pins[./images/dungeon.png]{labelStart="1"}
|
:md-pins[./images/dungeon.png]{labelStart="1"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `:md-commander`
|
|
||||||
|
|
||||||
Interactive command history / tracker panel.
|
|
||||||
|
|
||||||
The body is a CSV of command templates (`command,parameter,label,description,insertedText`).
|
|
||||||
|
|
||||||
- Body: command template CSV
|
|
||||||
- Attributes: `placeholder`, `class`, `height`
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
:md-commander[./commands.csv]{placeholder="输入命令…" height="400px"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `:md-yarn-spinner`
|
### `:md-yarn-spinner`
|
||||||
|
|
||||||
Runs a Yarn Spinner dialogue file.
|
Runs a Yarn Spinner dialogue file.
|
||||||
@@ -270,6 +287,10 @@ Renders a clickable span that dispatches a journal command.
|
|||||||
The `role=` attribute on the info string determines how a fenced block is
|
The `role=` attribute on the info string determines how a fenced block is
|
||||||
handled.
|
handled.
|
||||||
|
|
||||||
|
Fences may use backticks or tildes (`~~~`); the closing fence must reuse
|
||||||
|
the opening marker. Fence attribute keys may contain hyphens (e.g.
|
||||||
|
`data-foo=bar`).
|
||||||
|
|
||||||
### `role=spark-table`
|
### `role=spark-table`
|
||||||
|
|
||||||
CSV (or markdown pipe table) rendered as `:md-table`.
|
CSV (or markdown pipe table) rendered as `:md-table`.
|
||||||
@@ -297,13 +318,19 @@ d6,结果
|
|||||||
|
|
||||||
Reactive variable / tag-modifier definitions.
|
Reactive variable / tag-modifier definitions.
|
||||||
|
|
||||||
Columns (any order): `tag`, `threshold`, `key`, `expr`. A row with an empty `tag` declares a variable; a row with a `#tag` applies a modifier when that tag is active.
|
YAML with two optional maps. `vars:` maps a `$name` to its expression. `tags:` maps a tag name to a map of thresholds to `$target` modifiers: at that tag count the target gains the value, so sibling thresholds stack. Tag names may be written with or without the leading `#`. YAML resolves bare numbers to numbers and everything else to strings; both become expression text.
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
```csv role=declare
|
```yaml role=declare
|
||||||
tag,threshold,key,expr
|
vars:
|
||||||
,,$hp,$con*5+$mod_hp
|
$hp: $con*5 + $mod_hp
|
||||||
#warrior,2,$mod_hp,20
|
$ac: 10 + $dex
|
||||||
|
tags:
|
||||||
|
warrior:
|
||||||
|
1:
|
||||||
|
$mod_hp: 20
|
||||||
|
2:
|
||||||
|
$mod_str: 1
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
@@ -347,6 +374,7 @@ Used by `:md-dice`, spark-table headers, and the journal `/roll`:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `2d6` | two six-sided dice |
|
| `2d6` | two six-sided dice |
|
||||||
| `1d20+5` | d20 plus modifier |
|
| `1d20+5` | d20 plus modifier |
|
||||||
|
| `d20` | one twenty-sided die (count optional) |
|
||||||
| `3d6kh1` | keep highest 1 |
|
| `3d6kh1` | keep highest 1 |
|
||||||
| `3d6dl2-1` | drop lowest 2, minus 1 |
|
| `3d6dl2-1` | drop lowest 2, minus 1 |
|
||||||
| `{d4, d8}` | choose one of the listed dice |
|
| `{d4, d8}` | choose one of the listed dice |
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Writing rules documents
|
||||||
|
|
||||||
|
A rules document is the markdown printed with `:md-print[./rules.md]` (see
|
||||||
|
`reference.md` for the element). It is read *at the table*, so it is written to
|
||||||
|
be scanned, not studied. These conventions keep it dense and fast to use.
|
||||||
|
|
||||||
|
## Document structure
|
||||||
|
|
||||||
|
Every rules document follows the same five-part skeleton, in order:
|
||||||
|
|
||||||
|
1. **Briefing** — what the game is and what the players are doing.
|
||||||
|
2. **Components** — the pieces in play (cards, tokens, sheets, dice).
|
||||||
|
3. **Setup** — how to get from the box to the first turn.
|
||||||
|
4. **Game flow** — the loop of play, turn by turn.
|
||||||
|
5. **Game end** — how the game finishes and who wins.
|
||||||
|
|
||||||
|
Use these as `##` headings so a reader can jump straight to the part they need.
|
||||||
|
|
||||||
|
## Theme, players, and time
|
||||||
|
|
||||||
|
Open the briefing with one sentence that explains the theme — the fiction the
|
||||||
|
players step into — then state the player count range and the time estimate:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Briefing
|
||||||
|
|
||||||
|
You are rival merchants trading spice across a desert that wants you dead.
|
||||||
|
|
||||||
|
**2–4 players · 30–45 minutes**
|
||||||
|
```
|
||||||
|
|
||||||
|
## Icon-first mentions
|
||||||
|
|
||||||
|
When a paragraph mentions a concept for the first time, and that concept has an
|
||||||
|
icon under `assets/`, prefix the concept with the icon. The icon becomes a
|
||||||
|
visual anchor the reader can find without reading the whole sentence.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Spend a :[coin]coin to bribe the :[guard]guard.
|
||||||
|
```
|
||||||
|
|
||||||
|
Write the icon immediately before the word, with no space: `:[coin]coin`, not
|
||||||
|
`:[coin] coin`. Only the **first** mention in a paragraph gets the icon; later
|
||||||
|
mentions stay plain so the prose does not turn into a wall of icons. See
|
||||||
|
`reference.md` for the `:[name]` inline directive.
|
||||||
|
|
||||||
|
## Veteran-first prose
|
||||||
|
|
||||||
|
Write the default text for a veteran player who only needs to refresh their
|
||||||
|
memory: the rule, the number, the trigger. Assume they already know the game.
|
||||||
|
|
||||||
|
Everything that is *not* needed to play — edge cases, worked examples, the
|
||||||
|
reasoning behind a rule, theme and flavour — is look-up material for new players
|
||||||
|
and corner cases. Box it in a GitHub alert so it stays out of the way of the
|
||||||
|
main line:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
A :[shield]shield reduces incoming damage by 2.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Shields do not stack. A character carrying two shields still reduces damage
|
||||||
|
> by 2, and a shield offers no protection against fire.
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick the alert type to signal intent: `[!NOTE]` for clarifications and edge
|
||||||
|
cases, `[!TIP]` for advice, `[!IMPORTANT]` for rules that are easy to miss,
|
||||||
|
`[!WARNING]` / `[!CAUTION]` for consequences. See `markdown.md` for the alert
|
||||||
|
syntax.
|
||||||
|
|
||||||
|
The test: if a veteran can play correctly from the plain text alone, the split
|
||||||
|
is right. If a rule only exists inside an alert, it belongs in the main text.
|
||||||
|
|
||||||
|
## Rule timestamp
|
||||||
|
|
||||||
|
End every document with a timestamp, so a printed copy can be checked against
|
||||||
|
the current rules:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
|
||||||
|
*2026-10-24*
|
||||||
|
```
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
`` ```yaml role=tag `` renders a custom element from YAML. `tag` names the
|
`` ```yaml role=tag `` renders a custom element from YAML. `tag` names the
|
||||||
element, `body` becomes its content, and any other key becomes an attribute.
|
element, `body` becomes its content, and any other key becomes an attribute.
|
||||||
Fence-line attributes override the YAML body. See `reference.md` for the
|
Fence-line attributes override the YAML body; both backtick and tilde (`~~~`)
|
||||||
element and role catalog.
|
fences work. See `reference.md` for the element and role catalog.
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
```yaml role=tag
|
```yaml role=tag
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Generated reference docs are emitted with LF by scripts/generate-reference.ts.
|
||||||
|
# Pin them to LF so the drift guard in src/cli/validate/known.test.ts compares
|
||||||
|
# like-for-like on Windows, where core.autocrlf would otherwise check them out
|
||||||
|
# as CRLF.
|
||||||
|
docs/reference.md text eol=lf
|
||||||
|
.agents/skills/tttk-authoring/reference.md text eol=lf
|
||||||
@@ -6,3 +6,4 @@ dist/
|
|||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
*.csv.d.ts
|
*.csv.d.ts
|
||||||
|
.ttrpg/
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ npx tttk check ./content
|
|||||||
| 🎲 骰子 | `:md-dice[2d6+d8]` | 掷骰并记录结果 |
|
| 🎲 骰子 | `:md-dice[2d6+d8]` | 掷骰并记录结果 |
|
||||||
| 📊 表格 | `:md-table[./data.csv]` | 标签页式表格 |
|
| 📊 表格 | `:md-table[./data.csv]` | 标签页式表格 |
|
||||||
| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局(推荐 `yaml role=tag` 写法) |
|
| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局(推荐 `yaml role=tag` 写法) |
|
||||||
|
| 🖨️ 打印版式 | `:md-print[./rules.md]` | A4 横版双 A5、多列排版并导出 PDF |
|
||||||
| 📍 标记 | `:md-pins[./map.png]{pins="A:25,50"}` | 图片标记 |
|
| 📍 标记 | `:md-pins[./map.png]{pins="A:25,50"}` | 图片标记 |
|
||||||
| 📋 追踪器 | `:md-commander[./commands.csv]` | 命令历史和状态追踪 |
|
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# Commander Test
|
|
||||||
|
|
||||||
:md-commander[./commands.csv]
|
|
||||||
|
|
||||||
## Lonelog
|
|
||||||
|
|
||||||
`我` 走进酒吧。
|
|
||||||
|
|
||||||
`oracle` 酒吧里人多吗?
|
|
||||||
|
|
||||||
`roll` d20 = 18 `prompt` 是的
|
|
||||||
|
|
||||||
`fact` 酒吧里有很多人。
|
|
||||||
|
|
||||||
:[blah]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
command,parameter,label,description,insertedText
|
|
||||||
roll,emmet,2d6,2D6,2d6
|
|
||||||
roll,emmet,d20,D20,d20
|
|
||||||
track,emmet,monk,破戒佛爷,"破戒佛爷.bandit.monk[gd=4/4,str=14/14,cla=10,spi=8]"
|
|
||||||
track,emmet,bandit,龙虎寨山贼,"龙虎寨山贼.bandit[gd=2/2,str=10/10,cla=10,spi=10]"
|
|
||||||
track,emmet,khitan,契丹护卫,"契丹护卫.khitan.guard[gd=4/4,ap=2,str=14/14,cla=14,spi=14]"
|
|
||||||
|
@@ -33,6 +33,21 @@ bler
|
|||||||
|
|
||||||
:md-table[./npcs.csv]{roll}
|
:md-table[./npcs.csv]{roll}
|
||||||
|
|
||||||
|
```csv role=spark-table
|
||||||
|
2d6,encounter
|
||||||
|
2,Tomato
|
||||||
|
3,Onion
|
||||||
|
4,Cabbage
|
||||||
|
5,Carrot
|
||||||
|
6,Potato
|
||||||
|
7,Leek
|
||||||
|
8,Pumpkin
|
||||||
|
9,Mushroom
|
||||||
|
10,Radish
|
||||||
|
11,Eggplant
|
||||||
|
12,Pepper
|
||||||
|
```
|
||||||
|
|
||||||
### 链接
|
### 链接
|
||||||
|
|
||||||
文本里面可以有:md-link[./stuff.md#啊什么]的啦
|
文本里面可以有:md-link[./stuff.md#啊什么]的啦
|
||||||
|
|||||||
+22
-15
@@ -39,14 +39,14 @@ tttk serve [dir] -p 3000
|
|||||||
| 选项 | 说明 | 默认值 |
|
| 选项 | 说明 | 默认值 |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| `-p, --port <port>` | 端口号 | `3000` |
|
| `-p, --port <port>` | 端口号 | `3000` |
|
||||||
| `-h, --host <host>` | 主机地址 | `0.0.0.0` |
|
| `--host <host>` | 主机地址 | `0.0.0.0` |
|
||||||
|
|
||||||
**功能:**
|
**功能:**
|
||||||
|
|
||||||
- 扫描目录下的所有 `.md`、`.csv`、`.yarn` 文件
|
- 扫描目录下的 `.md`、`.csv`、`.yarn`、`.svg` 文件(跳过以 `.` 开头的文件和 `node_modules`)
|
||||||
- 为每个文件创建路由
|
- 把内容目录挂在 URL 根路径下,文档 `rules/combat.md` 对应路由 `/rules/combat`
|
||||||
- 提供实时文件监听,修改后自动刷新
|
- 监听文件变化并重建内存中的内容注册表
|
||||||
- 通过 `/__CONTENT_INDEX.json` 提供文件索引
|
- 提供 `/__CONTENT_INDEX.json`、`/__CONTENT_REGISTRY.json`、`/__COMPLETIONS.json` 等接口(见[开发服务器特性](#开发服务器特性))
|
||||||
|
|
||||||
**示例:**
|
**示例:**
|
||||||
|
|
||||||
@@ -115,8 +115,8 @@ content/adventures/hollow-mill/adventure.md
|
|||||||
| `TTTK-D002` | error | 属性块畸形(例如未加引号的值中含空格) |
|
| `TTTK-D002` | error | 属性块畸形(例如未加引号的值中含空格) |
|
||||||
| `TTTK-R001` | error | 未知的代码块 `role=`(块内容会被静默丢弃) |
|
| `TTTK-R001` | error | 未知的代码块 `role=`(块内容会被静默丢弃) |
|
||||||
| `TTTK-R002` | warning | `role=spark-table` 首列表头不是骰点公式 |
|
| `TTTK-R002` | warning | `role=spark-table` 首列表头不是骰点公式 |
|
||||||
| `TTTK-R003` | error | `role=declare` 解析失败(缺少 `key`/`expr` 等) |
|
| `TTTK-R003` | error | `role=declare` 解析失败(YAML 非法或结构不符) |
|
||||||
| `TTTK-R004` | warning | `role=declare` 中 `key`/`expr` 为空的行被跳过 |
|
| `TTTK-R004` | warning | `role=declare` 块没有声明任何变量或标签修饰符 |
|
||||||
| `TTTK-R005` | error | `role=tag` 的 YAML 无法解析 |
|
| `TTTK-R005` | error | `role=tag` 的 YAML 无法解析 |
|
||||||
| `TTTK-R006` | error | `role=tag` 缺少 `tag:` 或元素未知 |
|
| `TTTK-R006` | error | `role=tag` 缺少 `tag:` 或元素未知 |
|
||||||
| `TTTK-C001` | error | 内容引用无法解析(文件不存在,或内联 id 未定义) |
|
| `TTTK-C001` | error | 内容引用无法解析(文件不存在,或内联 id 未定义) |
|
||||||
@@ -218,20 +218,27 @@ my-ttrpg-content/
|
|||||||
|
|
||||||
## 开发服务器特性
|
## 开发服务器特性
|
||||||
|
|
||||||
### 实时索引
|
### 内容接口
|
||||||
|
|
||||||
访问 `http://localhost:3000/__CONTENT_INDEX.json` 可获取当前内容目录的文件索引。
|
- `/__CONTENT_INDEX.json` — 原始文件索引(`{ 路径: 文件内容 }`)
|
||||||
|
- `/__CONTENT_REGISTRY.json` — 内容注册表(文件路径索引 + 文档内联内容)
|
||||||
|
- `/__COMPLETIONS.json` — 补全数据(骰点、链接、spark 表格、声明、标签修饰符)
|
||||||
|
|
||||||
### 自动刷新
|
### 内容目录与 URL 的对应关系
|
||||||
|
|
||||||
文件变化时,服务器会自动重新加载内容,无需手动刷新页面。
|
内容目录挂在 URL 根路径下,文档去掉 `.md` 扩展名后即为路由:
|
||||||
|
|
||||||
|
- 文档 `rules/combat.md` → `http://localhost:3000/rules/combat`
|
||||||
|
- 图片 `rules/maps/city.png` → `http://localhost:3000/rules/maps/city.png`
|
||||||
|
|
||||||
|
### 文件监听
|
||||||
|
|
||||||
|
服务器会监听内容目录的变化并重建内存中的注册表;重新加载页面即可看到最新内容。
|
||||||
|
|
||||||
### SPA 路由
|
### SPA 路由
|
||||||
|
|
||||||
使用 hash 路由支持单页应用导航:
|
路由基于 History API(而非 hash)。直接访问深层链接或刷新页面时,未知路径会回退到 `index.html`,
|
||||||
|
因此浏览器前进/后退与深链接均可正常工作。
|
||||||
- `/content/rules/combat.md` → `#/content/rules/combat.md`
|
|
||||||
- 支持浏览器前进/后退
|
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
|
|||||||
+41
-6
@@ -6,9 +6,13 @@
|
|||||||
# 安装依赖
|
# 安装依赖
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 开发模式(Web)
|
# 开发模式(同时启动 CLI 内容服务器 :3000 与 Web 前端 :5173)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
|
# 也可分别启动
|
||||||
|
npm run dev:cli
|
||||||
|
npm run dev:web
|
||||||
|
|
||||||
# 构建(Web)
|
# 构建(Web)
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
@@ -42,7 +46,6 @@ tttk/
|
|||||||
│ │ ├── md-table.tsx # 表格组件
|
│ │ ├── md-table.tsx # 表格组件
|
||||||
│ │ ├── md-deck/ # 卡牌组件
|
│ │ ├── md-deck/ # 卡牌组件
|
||||||
│ │ ├── md-pins.tsx # 标记组件
|
│ │ ├── md-pins.tsx # 标记组件
|
||||||
│ │ ├── md-commander/ # 命令追踪器
|
|
||||||
│ │ ├── md-yarn-spinner.tsx # 叙事线组件
|
│ │ ├── md-yarn-spinner.tsx # 叙事线组件
|
||||||
|
|
||||||
│ │ ├── Article.tsx # 文章组件
|
│ │ ├── Article.tsx # 文章组件
|
||||||
@@ -244,19 +247,51 @@ npm test -- --watch
|
|||||||
|
|
||||||
## 调试
|
## 调试
|
||||||
|
|
||||||
|
开发环境由两个进程组成,必须使用不同端口:
|
||||||
|
|
||||||
|
- Web 前端:rsbuild 开发服务器,端口 **5173**
|
||||||
|
- CLI 内容服务器:`tttk serve`,端口 **3000**(内容 API + Journal)
|
||||||
|
|
||||||
|
rsbuild 会把 `/__CONTENT_INDEX.json`、`/__COMPLETIONS.json`、`/__CONTENT_REGISTRY.json`、`/.ttrpg`
|
||||||
|
以及内容目录中的静态资源(图片、CSS、CSV 等)代理到 CLI 的 3000 端口,因此必须先启动 CLI,
|
||||||
|
否则前端拿不到内容。
|
||||||
|
|
||||||
|
CLI 把内容目录挂在 URL 根路径下(例如 `/adventures/map.png`,**不是** `/content/...`),
|
||||||
|
所以 rsbuild 会按“请求路径是否对应内容目录中的真实文件”来决定是否代理;`/static/` 下的
|
||||||
|
前端构建产物仍由 web dev server 自己提供。
|
||||||
|
|
||||||
### Web 开发
|
### Web 开发
|
||||||
|
|
||||||
|
`npm run dev` 会同时启动两个进程;也可以分开运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 同时启动 CLI(3000)与 Web(5173)
|
||||||
npm run dev
|
npm run dev
|
||||||
# 访问 http://localhost:3000
|
|
||||||
|
# 或分别启动
|
||||||
|
npm run dev:cli # CLI 内容服务器,占用 3000
|
||||||
|
npm run dev:web # Web 前端,占用 5173
|
||||||
|
# 访问 http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
### CLI 调试
|
### CLI 调试
|
||||||
|
|
||||||
```bash
|
CLI 通过 ts-node 运行,加上 `--inspect` 与 `--enable-source-maps` 后即可在编辑器中附加断点。
|
||||||
# 使用 ts-node 直接运行
|
如需调试启动/扫描逻辑,用 `--inspect-brk` 在用户代码执行前暂停:
|
||||||
npm run ttrpg serve ./content
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --inspect-brk --enable-source-maps --loader ts-node/esm ./src/cli/index.ts serve ./content
|
||||||
|
|
||||||
|
# 或使用脚本(不带 --inspect-brk)
|
||||||
|
npm run tttk:inspect -- serve ./content
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在 Zed / VS Code 中附加到 `localhost:9229`,或打开 `chrome://inspect`。
|
||||||
|
|
||||||
|
CLI 只监听内容目录(`.md`/`.csv`/`.yarn`/`.svg`)并重建内存中的注册表,**不会**热重载自身源码;
|
||||||
|
修改 `src/cli/**` 后需要重启进程。
|
||||||
|
|
||||||
|
```bash
|
||||||
# 或构建后运行
|
# 或构建后运行
|
||||||
npm run cli:build
|
npm run cli:build
|
||||||
node dist/cli/index.js serve ./content
|
node dist/cli/index.js serve ./content
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Icon migration — unicode/emoji → `solid-icons`
|
||||||
|
|
||||||
|
**Status: implemented.** This document is both the audit and the record of the
|
||||||
|
migration. It lists the pack/conventions used and the per-file mapping.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace ad-hoc unicode symbols and emoji in **UI chrome** with real SVG icon
|
||||||
|
components from [`solid-icons`](https://solid-icons.vercel.app), so icons are
|
||||||
|
consistent, themeable (`currentColor`, `size`), and render identically across
|
||||||
|
platforms (emoji fonts vary wildly between OSes).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In scope — UI chrome** (`src/components/**`, `src/App.tsx`, doc-entry
|
||||||
|
frontmatter, `RevealManager` inline SVGs, the editor-panel align PNGs).
|
||||||
|
|
||||||
|
**Out of scope — left as-is:**
|
||||||
|
|
||||||
|
| Thing | Why |
|
||||||
|
|---|---|
|
||||||
|
| `:[icon-name]` content directive (`src/markdown/icon.ts`, `assets/`) | Game-content icons resolved from user `assets/` dirs via CSS `background-image`. Not UI chrome. |
|
||||||
|
| CLI console output (`✅ ❌ 📦` in `src/cli/**`) | Terminal output, not a browser. `solid-icons` is a SolidJS/browser library. |
|
||||||
|
| Emoji in `README.md`, `docs/*.md` prose | Documentation text, not rendered UI. |
|
||||||
|
| Dimension text `×` in `PltPreview` / `PrintPreviewHeader` | A multiplication sign in "100mm × 50mm", not an icon. |
|
||||||
|
| `content/**` sample content | User-authored data. |
|
||||||
|
| `PltPreview.tsx` / `PrintPreview.tsx` `<svg>` | Rendered card/page artwork, not icons. |
|
||||||
|
|
||||||
|
## Pack
|
||||||
|
|
||||||
|
**Tabler** (`solid-icons/tb`). `solid-icons@1.2.0` does **not** ship Lucide; the
|
||||||
|
available packs are `ai, bi, bs, cg, fa, fi, hi, im, io, md, oc, ri, si, tb, ti,
|
||||||
|
vs, wi`. Tabler was chosen because it is stroke-based, uses `currentColor`, and
|
||||||
|
covers everything needed (dice, ruler, grip, alignment, arrows, …).
|
||||||
|
|
||||||
|
> Exports are prefixed `TbOutline*` (outline) and `TbFill*` (filled). Always use
|
||||||
|
> `TbOutline*`.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { TbOutlineFolderOpen, TbOutlineX } from "solid-icons/tb";
|
||||||
|
|
||||||
|
<TbOutlineFolderOpen size={16} class="text-gray-400" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- `size?: string | number` defaults to `"1em"`; `color` defaults to
|
||||||
|
`currentColor`. Icons therefore inherit the surrounding font-size and text
|
||||||
|
color, so existing Tailwind text-color/size classes keep working.
|
||||||
|
- **Inline alignment:** an inline SVG sits on the text baseline, so icons placed
|
||||||
|
inline next to text get `class="inline-block align-[-0.125em]"`. Icons inside
|
||||||
|
a `flex`/`inline-flex` container don't need it.
|
||||||
|
- **Fixed-size buttons** get an explicit `size` (`16` for `w-7 h-7`/`w-8 h-8`,
|
||||||
|
`14` for `w-5 h-5`/`w-6 h-6`).
|
||||||
|
- Text labels next to a glyph (e.g. `📥 导出 PDF`) are preserved; only the glyph
|
||||||
|
is replaced.
|
||||||
|
|
||||||
|
## Shared modules
|
||||||
|
|
||||||
|
- **`src/components/doc-icons.ts`** — maps doc-entry frontmatter `icon:` keys
|
||||||
|
(e.g. `dice`, `table`) to Tabler components. Frontmatter is YAML, so it can't
|
||||||
|
hold a component; it stores a semantic key instead. `docIcon(key)` falls back
|
||||||
|
to `TbOutlineFile`.
|
||||||
|
- **`CompletionItem.icon`** (`src/components/journal/command-parser.ts`) — an
|
||||||
|
optional `IconTypes` rendered by `CompletionsDropdown.tsx` before the label,
|
||||||
|
so completion entries carry real icons instead of emoji in the label string.
|
||||||
|
|
||||||
|
## Inventory
|
||||||
|
|
||||||
|
### Shell / navigation
|
||||||
|
|
||||||
|
| File | Glyph | Purpose | Icon |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `src/App.tsx` | `☰` | mobile menu | `TbOutlineMenu2` |
|
||||||
|
| `src/App.tsx` | `📋` | journal panel toggle | `TbOutlineClipboardList` |
|
||||||
|
| `src/App.tsx` | `?` | docs/help | `TbOutlineHelpCircle` |
|
||||||
|
| `src/components/Sidebar.tsx` | `📂` | content source | `TbOutlineFolderOpen` |
|
||||||
|
| `src/components/Sidebar.tsx` | `✕` | close sidebar | `TbOutlineX` |
|
||||||
|
| `src/components/FileTree.tsx` | `📂` / `📁` | folder open / closed | `TbOutlineFolderOpen` / `TbOutlineFolder` |
|
||||||
|
| `src/components/FileTree.tsx` | `📄` | file | `TbOutlineFile` |
|
||||||
|
| `src/components/FileTree.tsx` | `▶` | expand chevron | `TbOutlineChevronRight` |
|
||||||
|
| `src/components/DocDialog.tsx` | `▾` | dropdown caret | `TbOutlineChevronDown` |
|
||||||
|
| `src/components/DocDialog.tsx` | `📖` | directives doc set | `TbOutlineBook2` |
|
||||||
|
| `src/components/DocDialog.tsx` | `✓` | selected doc set | `TbOutlineCheck` |
|
||||||
|
| `src/components/DocDialog.tsx` | `📋` | journal doc set | `TbOutlineClipboardList` |
|
||||||
|
| `src/components/DocDialog.tsx` | `✕` | close dialog | `TbOutlineX` |
|
||||||
|
| `src/components/DocDialog.tsx` | entry `icon:` | doc entry icon | `<Dynamic component={docIcon(entry.icon)} />` |
|
||||||
|
|
||||||
|
### md-* components
|
||||||
|
|
||||||
|
| File | Glyph | Purpose | Icon |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `src/components/md-dice.tsx` | `🎲` | dice reset | `TbOutlineDice5` |
|
||||||
|
| `src/components/md-table.tsx` | `🎲` | random row | `TbOutlineDice5` |
|
||||||
|
| `src/components/md-pins.tsx` | `📋` | copy pin coords | `TbOutlineCopy` |
|
||||||
|
| `src/components/md-yarn-spinner.tsx` | `🔄` | restart | `TbOutlineRotate` |
|
||||||
|
| `src/components/md-yarn-spinner.tsx` | `⏩` | advance | `TbOutlinePlayerTrackNext` |
|
||||||
|
| `src/components/md-yarn-spinner.tsx` | `→` | option bullet | `TbOutlineArrowRight` |
|
||||||
|
|
||||||
|
### Journal
|
||||||
|
|
||||||
|
| File | Glyph | Purpose | Icon |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `src/components/journal/ConnectDialog.tsx` | `🗑` | delete session | `TbOutlineTrash` |
|
||||||
|
| `src/components/journal/CreateSessionDialog.tsx` | `✕` | close | `TbOutlineX` |
|
||||||
|
| `src/components/journal/DeleteSessionDialog.tsx` | `✕` | close | `TbOutlineX` |
|
||||||
|
| `src/components/journal/ErrorPopup.tsx` | `✕` | close | `TbOutlineX` |
|
||||||
|
| `src/components/journal/InviteDialog.tsx` | `✕` | close | `TbOutlineX` |
|
||||||
|
| `src/components/journal/JournalHeader.tsx` | `←` | back to session list | `TbOutlineArrowLeft` |
|
||||||
|
| `src/components/journal/JournalHeader.tsx` | `▸` | hide panel | `TbOutlineLayoutSidebarRightCollapse` |
|
||||||
|
| `src/components/journal/SessionDropdown.tsx` | `▾` | dropdown caret | `TbOutlineChevronDown` |
|
||||||
|
| `src/components/journal/SessionDropdown.tsx` | `✓` | active session | `TbOutlineCheck` |
|
||||||
|
| `src/components/journal/SessionDropdown.tsx` | `🗑` | delete session | `TbOutlineTrash` |
|
||||||
|
| `src/components/journal/StreamMessage.tsx` | `×` | revert message | `TbOutlineArrowBackUp` |
|
||||||
|
| `src/components/journal/types/intent.tsx` | `🙋` | intent | `TbOutlineHandStop` |
|
||||||
|
| `src/components/journal/types/intent.tsx` | `✅` | verdict | `TbOutlineCircleCheck` |
|
||||||
|
| `src/components/journal/types/link.tsx` | `📄` | link | `TbOutlineFileText` |
|
||||||
|
| `src/components/journal/types/roll.tsx` | `🎲` | roll | `TbOutlineDice5` |
|
||||||
|
| `src/components/journal/types/roll.tsx` | `→` | notation → total | `TbOutlineArrowRight` |
|
||||||
|
| `src/components/journal/types/var.tsx` | `🔢` | variable | `TbOutlineHash` |
|
||||||
|
| `src/components/journal/command-completions.ts` | `🎲` | dice completion | `TbOutlineDice5` (via `item.icon`) |
|
||||||
|
| `src/components/journal/command-completions.ts` | `✨` | spark completion | `TbOutlineSparkles` (via `item.icon`) |
|
||||||
|
|
||||||
|
### md-deck
|
||||||
|
|
||||||
|
| File | Glyph | Purpose | Icon |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `src/components/md-deck/DeckHeader.tsx` | `✓` / `✏️` | editing toggle | `TbOutlineCheck` / `TbOutlinePencil` |
|
||||||
|
| `src/components/md-deck/DeckHeader.tsx` | `📥` | export PDF | `TbOutlineDownload` |
|
||||||
|
| `src/components/md-deck/DeckHeader.tsx` | `📋` | copy code | `TbOutlineCopy` |
|
||||||
|
| `src/components/md-deck/PltPreview.tsx` | `📥` | download PLT | `TbOutlineDownload` |
|
||||||
|
| `src/components/md-deck/PrintPreviewFooter.tsx` | `❌` | export error | `TbOutlineCircleX` |
|
||||||
|
| `src/components/md-deck/PrintPreviewFooter.tsx` | `✕` | close | `TbOutlineX` |
|
||||||
|
| `src/components/md-deck/PrintPreviewHeader.tsx` | `➕` | add deck | `TbOutlinePlus` |
|
||||||
|
| `src/components/md-deck/PrintPreviewHeader.tsx` | `📐` | PLT preview | `TbOutlineRuler` |
|
||||||
|
| `src/components/md-deck/PrintPreviewHeader.tsx` | `📥` | export PDF | `TbOutlineDownload` |
|
||||||
|
| `src/components/md-deck/CardList.tsx` | `‹` | previous page | `TbOutlineChevronLeft` |
|
||||||
|
| `src/components/md-deck/CardList.tsx` | `›` | next page | `TbOutlineChevronRight` |
|
||||||
|
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `⠿` | drag handle | `TbOutlineGripVertical` |
|
||||||
|
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `👁` / `👁🗨` | visible / hidden | `TbOutlineEye` / `TbOutlineEyeOff` |
|
||||||
|
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `↑ → ↓ ←` | orientation | `TbOutlineArrowUp` / `TbOutlineArrowRight` / `TbOutlineArrowDown` / `TbOutlineArrowLeft` |
|
||||||
|
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `↖ ↑ ↗ ↙ ↓ ↘` | alignment | `TbOutlineArrowUpLeft` / `TbOutlineArrowUp` / `TbOutlineArrowUpRight` / `TbOutlineArrowDownLeft` / `TbOutlineArrowDown` / `TbOutlineArrowDownRight` |
|
||||||
|
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `✕` | delete layer | `TbOutlineTrash` |
|
||||||
|
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `align-*.png` | align presets | `TbOutlineAlignLeft` / `TbOutlineAlignCenter` / `TbOutlineAlignRight` |
|
||||||
|
|
||||||
|
The three `align-*.png` assets were deleted; the `icons/` directory is gone.
|
||||||
|
|
||||||
|
### `RevealManager.tsx` (formerly inline SVG strings)
|
||||||
|
|
||||||
|
| Former | Purpose | Icon |
|
||||||
|
|---|---|---|
|
||||||
|
| `LINK_SVG` | floating `/link` action | `TbOutlineLink` |
|
||||||
|
| `SPARK_SVG` | floating `/roll` spark action | `TbOutlineSparkles` |
|
||||||
|
|
||||||
|
`FloatingButton.svg: string` (rendered via `innerHTML`) became
|
||||||
|
`FloatingButton.icon: IconTypes`, rendered as `<Dynamic component={icon} />`
|
||||||
|
children.
|
||||||
|
|
||||||
|
### `src/doc-entries/*.md` frontmatter `icon:`
|
||||||
|
|
||||||
|
| File / tag | Key |
|
||||||
|
|---|---|
|
||||||
|
| `journal-gm.md` | `gm` |
|
||||||
|
| `journal-link.md` | `link` |
|
||||||
|
| `journal-player.md` | `player` |
|
||||||
|
| `journal-roll.md` | `roll` |
|
||||||
|
| `journal-set.md` | `set` |
|
||||||
|
| `journal-spark.md` | `spark` |
|
||||||
|
| `md-bg.md` | `background` |
|
||||||
|
| `md-border.md` | `border` |
|
||||||
|
| `md-deck.md` | `deck` |
|
||||||
|
| `md-dice.md` | `dice` |
|
||||||
|
| `md-embed.md` | `embed` |
|
||||||
|
| `md-font.md` | `font` |
|
||||||
|
| `md-link.md` | `link` |
|
||||||
|
| `md-pins.md` | `pins` |
|
||||||
|
| `md-table.md` | `table` |
|
||||||
|
| `md-yarn-spinner.md` | `yarn` |
|
||||||
|
|
||||||
|
Keys are resolved through `docIcon()` in `src/components/doc-icons.ts`.
|
||||||
|
|
||||||
|
## Follow-ups / not migrated (by design)
|
||||||
|
|
||||||
|
- Doc-entry **body prose** that referenced a replaced button glyph was reworded
|
||||||
|
to name the button (e.g. `md-dice.md` now says "点击骰子图标"). Remaining emoji
|
||||||
|
in `src/doc-entries/*.md` are illustrative — coloured status dots
|
||||||
|
(`🟢🟡🔴⚪` in `journal-gm.md`) — not UI icons.
|
||||||
|
- CLI console / MCP output (`✅ ❌ 📦` in `src/cli/**`) keeps its emoji: it is
|
||||||
|
terminal text, not rendered UI.
|
||||||
|
- `command-completions.ts` `CompletionItem` now carries an optional `icon`; any
|
||||||
|
future completion source can use it.
|
||||||
+65
-39
@@ -11,6 +11,10 @@
|
|||||||
- [marked-gfm-heading-id](https://github.com/markedjs/marked-gfm-heading-id) - 标题 ID
|
- [marked-gfm-heading-id](https://github.com/markedjs/marked-gfm-heading-id) - 标题 ID
|
||||||
- [marked-directive](https://github.com/fengzilong/marked-directive) - 指令语法
|
- [marked-directive](https://github.com/fengzilong/marked-directive) - 指令语法
|
||||||
|
|
||||||
|
### 代码块围栏
|
||||||
|
|
||||||
|
代码块围栏可使用反引号或波浪号(`~~~`)书写;闭合围栏必须与开启围栏使用相同字符,且不短于开启围栏。声明 `role=` 的代码块同理。
|
||||||
|
|
||||||
## 指令语法
|
## 指令语法
|
||||||
|
|
||||||
通过 `marked-directive` 支持自定义组件插入:
|
通过 `marked-directive` 支持自定义组件插入:
|
||||||
@@ -96,19 +100,19 @@
|
|||||||
|
|
||||||
**注意:** CSS class 名称始终使用不带扩展名的图标名称(如 `icon-attack`),方便统一样式控制。
|
**注意:** CSS class 名称始终使用不带扩展名的图标名称(如 `icon-attack`),方便统一样式控制。
|
||||||
|
|
||||||
### 配置图标前缀
|
### 图标查找路径
|
||||||
|
|
||||||
可通过 `iconPath` 属性指定图标目录:
|
图标没有单独的配置项。`:[name]` 会从当前文档所在目录开始,逐级向上查找 `assets/` 目录,
|
||||||
|
直到内容目录根:
|
||||||
|
|
||||||
```markdown
|
```text
|
||||||
<Article src="/content/page.md" iconPath="./assets/icons" />
|
rules/combat.md 中的 :[attack]
|
||||||
|
→ rules/assets/attack.png
|
||||||
|
→ assets/attack.png
|
||||||
```
|
```
|
||||||
|
|
||||||
或使用空字符串禁用图标前缀:
|
因此把图标放在内容目录(或其子目录)的 `assets/` 下即可。`tttk check` 会用同样的规则
|
||||||
|
校验图标是否存在(诊断 `TTTK-A001`)。
|
||||||
```markdown
|
|
||||||
<Article src="/content/page.md" iconPath="" />
|
|
||||||
```
|
|
||||||
|
|
||||||
## 组件库
|
## 组件库
|
||||||
|
|
||||||
@@ -123,6 +127,7 @@
|
|||||||
- 点击文本执行掷骰
|
- 点击文本执行掷骰
|
||||||
- 点击骰子图标重置为公式
|
- 点击骰子图标重置为公式
|
||||||
- `key` 属性将结果记录到 URL 参数中 (`?dice-attack=15`)
|
- `key` 属性将结果记录到 URL 参数中 (`?dice-attack=15`)
|
||||||
|
- 骰子数量可省略,`d20` 等价于 `1d20`
|
||||||
|
|
||||||
**属性:**
|
**属性:**
|
||||||
|
|
||||||
@@ -453,6 +458,57 @@ data-config:
|
|||||||
|
|
||||||
每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`(水平 `l`/`c`/`r`,可加垂直前缀 `t`/`b` 组成 `tl`/`tc`/`tr`/`bl`/`bc`/`br`,如 `align: tl` 表示左上对齐)。`back_layers` 用于背面图层。
|
每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`(水平 `l`/`c`/`r`,可加垂直前缀 `t`/`b` 组成 `tl`/`tc`/`tr`/`bl`/`bc`/`br`,如 `align: tl` 表示左上对齐)。`back_layers` 用于背面图层。
|
||||||
|
|
||||||
|
### 🖨️ 打印版式组件 (md-print)
|
||||||
|
|
||||||
|
将 Markdown 文档排版为可打印的 A4 横版页面:每张 A4 放两个 A5 页面,每个 A5 页面再分成多列。
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
:md-print[./rules.md]
|
||||||
|
:md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 自动分页:内容按块级元素流入 A5 页面,段落、列表和表格可在分页处拆分
|
||||||
|
- 每张 A4 横版放两个 A5 页面,每个 A5 页面默认两列
|
||||||
|
- 预览中可直接打印或另存为 PDF
|
||||||
|
- 支持页眉标题、页码、导出时间戳和中缝裁切线
|
||||||
|
|
||||||
|
**属性:**
|
||||||
|
|
||||||
|
| 属性 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `columns` | number | `2` | 每个 A5 页面的列数(1–4) |
|
||||||
|
| `margin` | number | `10` | 页面边距(毫米) |
|
||||||
|
| `gap` | number | `5` | 列间距(毫米) |
|
||||||
|
| `font` | number | `3` | 正文字号(毫米) |
|
||||||
|
| `title` | string | - | 页眉标题 |
|
||||||
|
| `pageNumbers` | boolean | `true` | 是否在页面外侧角落显示页码 |
|
||||||
|
| `timestamp` | boolean | `false` | 是否在页码旁边显示导出时间 |
|
||||||
|
| `cutMarks` | boolean | `true` | 是否显示 A4 中缝裁切线 |
|
||||||
|
|
||||||
|
**YAML 写法:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tag: md-print
|
||||||
|
body: ./rules.md
|
||||||
|
columns: 2
|
||||||
|
margin: 10
|
||||||
|
gap: 5
|
||||||
|
font: 3
|
||||||
|
title: 规则书
|
||||||
|
timestamp: true
|
||||||
|
```
|
||||||
|
|
||||||
|
**导出时间戳:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
:md-print[./rules.md]{timestamp=true}
|
||||||
|
```
|
||||||
|
|
||||||
|
开启后页脚会显示类似 `3 / 12 · 2026-10-23 14:05` 的内容。页码位于页面外侧底角:左侧 A5 在左下角,右侧 A5 在右下角。
|
||||||
|
|
||||||
|
**打印设置:** 在浏览器打印对话框中选择 A4、横向、无边距、100% 缩放。
|
||||||
|
|
||||||
### 🧶 叙事线组件 (md-yarn-spinner)
|
### 🧶 叙事线组件 (md-yarn-spinner)
|
||||||
|
|
||||||
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
|
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
|
||||||
@@ -461,36 +517,6 @@ data-config:
|
|||||||
:md-yarn-spinner[./story.yarn]
|
:md-yarn-spinner[./story.yarn]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 📋 命令追踪器 (md-commander)
|
|
||||||
|
|
||||||
支持命令历史和状态追踪。
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
:md-commander
|
|
||||||
```
|
|
||||||
|
|
||||||
**追踪器命令:**
|
|
||||||
|
|
||||||
```
|
|
||||||
track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Emmet 简写语法:**
|
|
||||||
|
|
||||||
- `#id` - 设置 ID
|
|
||||||
- `.class` - 添加类别
|
|
||||||
- `[attr=value]` - 设置属性
|
|
||||||
|
|
||||||
**属性类型:**
|
|
||||||
|
|
||||||
| 类型 | 格式 | 显示 |
|
|
||||||
|------|------|------|
|
|
||||||
| progress | `x/y` | 进度条 |
|
|
||||||
| count | 整数 | 数字计数器 |
|
|
||||||
| string | 文本 | 文本字段 |
|
|
||||||
|
|
||||||
## YAML 标签
|
## YAML 标签
|
||||||
|
|
||||||
使用 ```yaml role=tag 代码块创建自定义标签(`yaml` 语言可被语法高亮):
|
使用 ```yaml role=tag 代码块创建自定义标签(`yaml` 语言可被语法高亮):
|
||||||
|
|||||||
+3
-3
@@ -246,10 +246,12 @@ ttrpg mcp generate-card-deck \
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "预览文件已创建",
|
"message": "预览文件已创建",
|
||||||
"preview_url": "http://localhost:3000/#/content/cards.md"
|
"preview_url": "http://localhost:3000/cards"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`preview_url` 以服务器工作目录为基准,去掉了 `.md` 扩展名(需要 `tttk serve` 正在运行)。
|
||||||
|
|
||||||
## CSV 文件格式
|
## CSV 文件格式
|
||||||
|
|
||||||
CSV 文件使用 YAML frontmatter 定义模板和配置:
|
CSV 文件使用 YAML frontmatter 定义模板和配置:
|
||||||
@@ -335,8 +337,6 @@ MCP 服务器提供以下 Resources,包含 TTRPG Tools 的文档和参考材
|
|||||||
- `:md-table` - 表格组件
|
- `:md-table` - 表格组件
|
||||||
- `:md-deck` - 卡牌组件
|
- `:md-deck` - 卡牌组件
|
||||||
- `:md-yarn-spinner` - 叙事线组件
|
- `:md-yarn-spinner` - 叙事线组件
|
||||||
|
|
||||||
- `:md-commander` - 命令追踪器
|
|
||||||
- YAML 标签
|
- YAML 标签
|
||||||
- Mermaid 图表
|
- Mermaid 图表
|
||||||
- 警告/提示块
|
- 警告/提示块
|
||||||
|
|||||||
+47
-19
@@ -48,13 +48,13 @@ Nest with more colons (`::::`, `:::::`).
|
|||||||
| `:md-dice` | dice formula | `key` |
|
| `:md-dice` | dice formula | `key` |
|
||||||
| `:md-table` | CSV path or inline id | `roll`, `remix` |
|
| `:md-table` | CSV path or inline id | `roll`, `remix` |
|
||||||
| `:md-deck` | CSV path | `size`, `grid`, `layers` |
|
| `:md-deck` | CSV path | `size`, `grid`, `layers` |
|
||||||
|
| `:md-print` | markdown path, optional #section | `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `timestamp`, `cutMarks` |
|
||||||
| `:md-link` | path, optional #section | — |
|
| `:md-link` | path, optional #section | — |
|
||||||
| `:md-embed` | path, optional #section | `headingBase` |
|
| `:md-embed` | path, optional #section | `headingBase` |
|
||||||
| `:md-bg` | image path or CSS color | `fit` |
|
| `:md-bg` | image path or CSS color | `fit` |
|
||||||
| `:md-border` | color or border image | `width`, `slice` |
|
| `:md-border` | color or border image | `width`, `slice` |
|
||||||
| `:md-font` | font family | `source`, `weight`, `color` |
|
| `:md-font` | font family | `source`, `weight`, `color` |
|
||||||
| `:md-pins` | image path | `pins`, `fixed`, `labelStart` |
|
| `:md-pins` | image path | `pins`, `fixed`, `labelStart` |
|
||||||
| `:md-commander` | command template CSV | `placeholder`, `class`, `height` |
|
|
||||||
| `:md-yarn-spinner` | Yarn Spinner file | `start` |
|
| `:md-yarn-spinner` | Yarn Spinner file | `start` |
|
||||||
|
|
||||||
### `:md-dice`
|
### `:md-dice`
|
||||||
@@ -112,6 +112,36 @@ Prefer the `yaml role=tag` form — it is the only form that supports template l
|
|||||||
:md-deck[./cards.csv]{size="63x88" grid="5x5" layers="title:1,1-5,1f8 body:1,5-5,8f3"}
|
:md-deck[./cards.csv]{size="63x88" grid="5x5" layers="title:1,1-5,1f8 body:1,5-5,8f3"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `:md-print`
|
||||||
|
|
||||||
|
Paginates a markdown document into A4 landscape sheets for printing.
|
||||||
|
|
||||||
|
Each sheet holds two A5 pages side by side, and each A5 page flows the markdown into `columns` columns. `margin`, `gap`, and `font` are in millimetres (the body font defaults to 3mm). `pageNumbers` adds an `n / total` footer in the outer corner, `timestamp` appends the export time to that footer, and `cutMarks` draws the centre cut line. The preview modal exports through the browser's print dialog (choose A4 landscape, no margins, 100% scale).
|
||||||
|
|
||||||
|
- Body: markdown path, optional #section
|
||||||
|
- Attributes: `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `timestamp`, `cutMarks`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
:md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
:md-print[./rules.md]{timestamp=true}
|
||||||
|
```
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```yaml role=tag
|
||||||
|
tag: md-print
|
||||||
|
body: ./rules.md
|
||||||
|
columns: 2
|
||||||
|
margin: 10
|
||||||
|
gap: 5
|
||||||
|
font: 3
|
||||||
|
title: 规则书
|
||||||
|
timestamp: true
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
### `:md-link`
|
### `:md-link`
|
||||||
|
|
||||||
Clickable link that expands the target article inline.
|
Clickable link that expands the target article inline.
|
||||||
@@ -205,19 +235,6 @@ Numbered markers on an image.
|
|||||||
:md-pins[./images/dungeon.png]{labelStart="1"}
|
:md-pins[./images/dungeon.png]{labelStart="1"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `:md-commander`
|
|
||||||
|
|
||||||
Interactive command history / tracker panel.
|
|
||||||
|
|
||||||
The body is a CSV of command templates (`command,parameter,label,description,insertedText`).
|
|
||||||
|
|
||||||
- Body: command template CSV
|
|
||||||
- Attributes: `placeholder`, `class`, `height`
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
:md-commander[./commands.csv]{placeholder="输入命令…" height="400px"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `:md-yarn-spinner`
|
### `:md-yarn-spinner`
|
||||||
|
|
||||||
Runs a Yarn Spinner dialogue file.
|
Runs a Yarn Spinner dialogue file.
|
||||||
@@ -270,6 +287,10 @@ Renders a clickable span that dispatches a journal command.
|
|||||||
The `role=` attribute on the info string determines how a fenced block is
|
The `role=` attribute on the info string determines how a fenced block is
|
||||||
handled.
|
handled.
|
||||||
|
|
||||||
|
Fences may use backticks or tildes (`~~~`); the closing fence must reuse
|
||||||
|
the opening marker. Fence attribute keys may contain hyphens (e.g.
|
||||||
|
`data-foo=bar`).
|
||||||
|
|
||||||
### `role=spark-table`
|
### `role=spark-table`
|
||||||
|
|
||||||
CSV (or markdown pipe table) rendered as `:md-table`.
|
CSV (or markdown pipe table) rendered as `:md-table`.
|
||||||
@@ -297,13 +318,19 @@ d6,结果
|
|||||||
|
|
||||||
Reactive variable / tag-modifier definitions.
|
Reactive variable / tag-modifier definitions.
|
||||||
|
|
||||||
Columns (any order): `tag`, `threshold`, `key`, `expr`. A row with an empty `tag` declares a variable; a row with a `#tag` applies a modifier when that tag is active.
|
YAML with two optional maps. `vars:` maps a `$name` to its expression. `tags:` maps a tag name to a map of thresholds to `$target` modifiers: at that tag count the target gains the value, so sibling thresholds stack. Tag names may be written with or without the leading `#`. YAML resolves bare numbers to numbers and everything else to strings; both become expression text.
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
```csv role=declare
|
```yaml role=declare
|
||||||
tag,threshold,key,expr
|
vars:
|
||||||
,,$hp,$con*5+$mod_hp
|
$hp: $con*5 + $mod_hp
|
||||||
#warrior,2,$mod_hp,20
|
$ac: 10 + $dex
|
||||||
|
tags:
|
||||||
|
warrior:
|
||||||
|
1:
|
||||||
|
$mod_hp: 20
|
||||||
|
2:
|
||||||
|
$mod_str: 1
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
@@ -347,6 +374,7 @@ Used by `:md-dice`, spark-table headers, and the journal `/roll`:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `2d6` | two six-sided dice |
|
| `2d6` | two six-sided dice |
|
||||||
| `1d20+5` | d20 plus modifier |
|
| `1d20+5` | d20 plus modifier |
|
||||||
|
| `d20` | one twenty-sided die (count optional) |
|
||||||
| `3d6kh1` | keep highest 1 |
|
| `3d6kh1` | keep highest 1 |
|
||||||
| `3d6dl2-1` | drop lowest 2, minus 1 |
|
| `3d6dl2-1` | drop lowest 2, minus 1 |
|
||||||
| `{d4, d8}` | choose one of the listed dice |
|
| `{d4, d8}` | choose one of the listed dice |
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Variable store — outstanding fixes
|
||||||
|
|
||||||
|
Notes from an audit of the client-side variable / tag reactivity system:
|
||||||
|
|
||||||
|
- `src/components/journal/var-reactivity.ts` — engine (dependency graph, base/mod
|
||||||
|
separation, threshold-gated tag activation, cascade).
|
||||||
|
- `src/components/journal/variable-expression.ts` — expression evaluator.
|
||||||
|
- `src/components/journal/command-dispatcher.ts` — `/set`, `/rolltag`, cascade.
|
||||||
|
- `src/components/journal/completions.ts` — init + seed.
|
||||||
|
- `src/components/stores/journalStream.ts` — replay + hydration rebuild.
|
||||||
|
- `src/components/journal/types/var.tsx` — `var` message + reducer.
|
||||||
|
|
||||||
|
## Already fixed (context)
|
||||||
|
|
||||||
|
Commit `a26d53c` (`fix(journal): keep variable bases in the stream`):
|
||||||
|
|
||||||
|
- Hydration double-counted active tag modifiers because the stream carried only
|
||||||
|
combined values. `var` messages now carry `base` alongside `value`, the store
|
||||||
|
keeps `variableBases`, and `rebuildReactivityFromStore` restores exact bases.
|
||||||
|
- Seeding ran at import against an empty store (clobbering persisted values and
|
||||||
|
appending duplicate `var` messages on every load). It now runs after hydration
|
||||||
|
and only for keys absent from the store.
|
||||||
|
|
||||||
|
The items below are still open.
|
||||||
|
|
||||||
|
## Open issues
|
||||||
|
|
||||||
|
### 1. Modifier and declaration errors are swallowed — ✅ fixed
|
||||||
|
|
||||||
|
- **Where:** `computeInitialValues` (`catch {}`, "skip failed evaluations at init
|
||||||
|
time") and `applyTagMapActivations` (`catch {}`, "skip failed modifier") in
|
||||||
|
`var-reactivity.ts`; `initReactivity` throws are reduced to `console.warn` in
|
||||||
|
`completions.ts`.
|
||||||
|
- **Symptom:** a typo'd declaration or modifier expression silently yields a
|
||||||
|
wrong value (often `0`) with no author-facing feedback.
|
||||||
|
- **Fixed:** the engine now collects `ReactivityDiagnostic` entries for failed
|
||||||
|
declarations (`computeInitialValues`, `reevaluateDependents`), failed modifier
|
||||||
|
expressions (`applyTagMapActivations`), and circular declarations
|
||||||
|
(`initReactivity`). They are deduped, logged via `console.warn` with
|
||||||
|
key/expression/tag context, cleared on each `initReactivity`, and bridged into
|
||||||
|
a signal exposed as `useJournalCompletions().diagnostics()`. `VariableView`
|
||||||
|
renders them as a warning banner. A bad declaration no longer aborts the rest
|
||||||
|
of a cascade.
|
||||||
|
|
||||||
|
### 2. `var del` does not reach the engine — ✅ fixed (dropped)
|
||||||
|
|
||||||
|
- **Where:** reducer in `types/var.tsx` supports `action: "del"`; the parser
|
||||||
|
(`command-parser.ts`) has no `/del`; `var-reactivity.ts` never clears
|
||||||
|
`baseValues` and `rebuildReactivityFromStore` never removes stale keys.
|
||||||
|
- **Symptom:** a deleted variable still resolves locally via `getCombined`,
|
||||||
|
because it reads `baseValues` first.
|
||||||
|
- **Fixed:** `/del` was never reachable (no parser emits it, and replay does not
|
||||||
|
validate), so the `del` action was removed from the `var` schema, render, and
|
||||||
|
reducer. The wire type is now `action: "set"` only; a legacy `del` message in
|
||||||
|
an old stream is a no-op on replay rather than resurrecting a deleted key.
|
||||||
|
|
||||||
|
### 3. Reactivity is initialized once, at import — ✅ fixed
|
||||||
|
|
||||||
|
- **Where:** `bootstrapReactivity` in `completions.ts` runs `initReactivity`
|
||||||
|
once; nothing re-runs it when declare blocks change or a session/document is
|
||||||
|
switched.
|
||||||
|
- **Also:** `DispatchContext.declarations` / `tagModifiers`
|
||||||
|
(`command-dispatcher.ts`) are declared but never read — a vestige of a
|
||||||
|
per-dispatch init that was never wired up.
|
||||||
|
- **Symptom:** editing `role=declare` content requires a full reload; stale
|
||||||
|
graph/expressions persist.
|
||||||
|
- **Fixed:** `bootstrapReactivity` is now `applyReactivity` behind an idempotent
|
||||||
|
`ensureReactivity()` that reads the live completions signal, compares a
|
||||||
|
signature, and re-initializes only when the declaration set changes.
|
||||||
|
`dispatchCommand` calls it before every dispatch, so a content rescan takes
|
||||||
|
effect on the next command without a reload. The unused
|
||||||
|
`DispatchContext.declarations` / `tagModifiers` fields were removed (they were
|
||||||
|
mount-time snapshots and could not be trusted). Session switches were already
|
||||||
|
covered by the hydration rebuild, which now also passes
|
||||||
|
`journalStreamState.variableBases` so bases are restored exactly instead of
|
||||||
|
falling back to combined values.
|
||||||
|
|
||||||
|
### 4. `lookup` contract disagrees with its docs — ✅ fixed
|
||||||
|
|
||||||
|
- **Where:** `EvalContext.lookup` documents "Resolve `$var`", but
|
||||||
|
`parseFactor` calls `ctx.lookup(varName.slice(1))` (bare name), and every
|
||||||
|
caller re-adds `$`.
|
||||||
|
- **Symptom:** a caller following the doc would key variables with `$` and
|
||||||
|
silently get `0`s.
|
||||||
|
- **Fixed:** `EvalContext.lookup` now receives the full `$var` name (including
|
||||||
|
`$`), consistent with every other variable API (`getCombined`, `setBase`,
|
||||||
|
`getMods`, `resolveVariable`). Callers no longer add or strip `$`.
|
||||||
|
|
||||||
|
### 5. Undefined variables evaluate to `0` — ✅ fixed (strict for commands)
|
||||||
|
|
||||||
|
- **Where:** `parseFactor` (`variable-expression.ts`) returns a numeric `0` for
|
||||||
|
an unresolved `$var`.
|
||||||
|
- **Symptom:** `/set $hp $cn*5` (typo) silently sets `0` instead of failing.
|
||||||
|
- **Fixed:** `parseFactor` now throws `Unknown variable: $x` whenever `lookup`
|
||||||
|
returns `undefined`, and `resolveVariable()` distinguishes unknown from a real
|
||||||
|
`0`. `/set` and `/roll` use the strict resolver, so typos fail loudly via
|
||||||
|
`dispatchError`. Declarations and tag modifiers stay lenient on purpose: they
|
||||||
|
resolve unknown references to `0` through `getCombined`, because the
|
||||||
|
documented pattern (`$hp = $con*5+$mod_hp`) relies on external/undeclared
|
||||||
|
variables defaulting to 0.
|
||||||
|
|
||||||
|
### 6. Tokenizer accepts malformed numbers — ✅ fixed
|
||||||
|
|
||||||
|
- **Where:** the number branch of `tokenize` consumes `[0-9.]` greedily, so
|
||||||
|
`1.2.3` becomes one "number" and `parseFloat` truncates it.
|
||||||
|
- **Fixed:** the tokenizer now validates the scanned chunk against
|
||||||
|
`^\d+(\.\d+)?$` and throws `Invalid number literal: "…"` instead of pushing a
|
||||||
|
silently-truncated value. The error propagates through the diagnostics channel
|
||||||
|
(#1) for declarations/modifiers and through `dispatchError` for `/set` and
|
||||||
|
`/roll`.
|
||||||
|
|
||||||
|
### 7. `topoSortAffected` re-evaluates untouched declarations — ✅ fixed
|
||||||
|
|
||||||
|
- **Where:** in `var-reactivity.ts`, the visit condition
|
||||||
|
`affected.has(dep) || declExprs.has(dep)` pulls declared dependencies that are
|
||||||
|
not in `affected` into the result.
|
||||||
|
- **Symptom:** `reevaluateDependents` re-evaluates and re-publishes variables
|
||||||
|
that did not change. Mostly harmless, but noisy — and a re-evaluated
|
||||||
|
declaration could overwrite a value that had just been `/set`.
|
||||||
|
- **Fixed:** the visit condition now only follows dependencies in `affected`,
|
||||||
|
so a cascade re-evaluates exactly the changed variable's dependents.
|
||||||
|
|
||||||
|
### 8. Failed `initReactivity` leaves partial state — ✅ fixed
|
||||||
|
|
||||||
|
- **Where:** `initReactivity` populates `depGraph` / `declExprs` / `tagModMap`
|
||||||
|
before `checkCircular()` throws; `inFlight` is never cleared on re-init.
|
||||||
|
- **Fixed:** `initReactivity` now builds the declaration map, dependency graph,
|
||||||
|
and tag-modifier map into locals and commits them only after `checkCircular`
|
||||||
|
passes, so a failed (re-)init leaves the previous engine intact. Runtime state
|
||||||
|
(`baseValues`, mods, `sourceActivations`) and `inFlight` are reset on commit.
|
||||||
|
|
||||||
|
### 9. Residual base / tagmap-target edges — low
|
||||||
|
|
||||||
|
- **Base fallback:** `rebuildReactivityFromStore` falls back to the combined
|
||||||
|
`value` when a message has no `base`, which double-counts mods. In-repo
|
||||||
|
emitters always send `base`; only hand-crafted/external `var` messages hit
|
||||||
|
this.
|
||||||
|
- **Tagmap targets:** rebuild derives activations from each source's *base*
|
||||||
|
tagmap (this fixed a feedback loop where a tagmap-valued target re-triggered
|
||||||
|
its own modifier on every rebuild). If a variable's effective count crosses a
|
||||||
|
threshold purely via mods, that activation is not reconstructed on hydration.
|
||||||
|
Revisit if tagmap-valued modifier targets come into use.
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
1. #3 (re-init) and #2 (`del`) — correctness of engine lifecycle.
|
||||||
|
2. #1 (diagnostics) — authoring feedback.
|
||||||
|
3. #4–#8 — cleanup and hardening.
|
||||||
|
4. #9 — only if the tagmap-target pattern is adopted.
|
||||||
Generated
+10
@@ -26,6 +26,7 @@
|
|||||||
"mermaid": "^11.0.0",
|
"mermaid": "^11.0.0",
|
||||||
"mqtt": "^5.15.1",
|
"mqtt": "^5.15.1",
|
||||||
"solid-element": "^1.9.1",
|
"solid-element": "^1.9.1",
|
||||||
|
"solid-icons": "^1.2.0",
|
||||||
"solid-js": "^1.9.3",
|
"solid-js": "^1.9.3",
|
||||||
"uuid": "^14.0.1",
|
"uuid": "^14.0.1",
|
||||||
"ws": "^8.21.0",
|
"ws": "^8.21.0",
|
||||||
@@ -9589,6 +9590,15 @@
|
|||||||
"solid-js": "^1.9.3"
|
"solid-js": "^1.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/solid-icons": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/solid-icons/-/solid-icons-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-yjQxWQMi9l19P5Af9vjsvalTMDFPDL4XD6etrUdNxNVhFp1bMYF0SFwUa5VYDfJ7u0SF3Qkz/F7BZxqcLUwklw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"solid-js": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/solid-js": {
|
"node_modules/solid-js": {
|
||||||
"version": "1.9.12",
|
"version": "1.9.12",
|
||||||
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.12.tgz",
|
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.12.tgz",
|
||||||
|
|||||||
+5
-1
@@ -19,7 +19,9 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "rsbuild dev",
|
"dev": "node scripts/dev.mjs",
|
||||||
|
"dev:web": "rsbuild dev",
|
||||||
|
"dev:cli": "node --enable-source-maps --loader ts-node/esm ./src/cli/index.ts serve ./content",
|
||||||
"build": "npm run build:web && npm run build:cli",
|
"build": "npm run build:web && npm run build:cli",
|
||||||
"build:web": "rsbuild build",
|
"build:web": "rsbuild build",
|
||||||
"build:cli": "tsc -p tsconfig.cli.json",
|
"build:cli": "tsc -p tsconfig.cli.json",
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
"cli:build": "npm run build:cli",
|
"cli:build": "npm run build:cli",
|
||||||
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
|
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
|
||||||
"tttk": "node --loader ts-node/esm ./src/cli/index.ts",
|
"tttk": "node --loader ts-node/esm ./src/cli/index.ts",
|
||||||
|
"tttk:inspect": "node --inspect --enable-source-maps --loader ts-node/esm ./src/cli/index.ts",
|
||||||
"gen:reference": "node --loader ts-node/esm ./scripts/generate-reference.ts",
|
"gen:reference": "node --loader ts-node/esm ./scripts/generate-reference.ts",
|
||||||
"gen:skill-reference": "node --loader ts-node/esm ./scripts/generate-reference.ts --out .agents/skills/tttk-authoring/reference.md",
|
"gen:skill-reference": "node --loader ts-node/esm ./scripts/generate-reference.ts --out .agents/skills/tttk-authoring/reference.md",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
@@ -61,6 +64,7 @@
|
|||||||
"mermaid": "^11.0.0",
|
"mermaid": "^11.0.0",
|
||||||
"mqtt": "^5.15.1",
|
"mqtt": "^5.15.1",
|
||||||
"solid-element": "^1.9.1",
|
"solid-element": "^1.9.1",
|
||||||
|
"solid-icons": "^1.2.0",
|
||||||
"solid-js": "^1.9.3",
|
"solid-js": "^1.9.3",
|
||||||
"uuid": "^14.0.1",
|
"uuid": "^14.0.1",
|
||||||
"ws": "^8.21.0",
|
"ws": "^8.21.0",
|
||||||
|
|||||||
+58
-6
@@ -3,6 +3,43 @@ import { pluginBabel } from "@rsbuild/plugin-babel";
|
|||||||
import { pluginSolid } from "@rsbuild/plugin-solid";
|
import { pluginSolid } from "@rsbuild/plugin-solid";
|
||||||
import tailwindcss from "@tailwindcss/postcss";
|
import tailwindcss from "@tailwindcss/postcss";
|
||||||
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
|
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
|
||||||
|
import { statSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
// The CLI content server (`npm run dev:cli`, i.e. `tttk serve ./content`) keeps
|
||||||
|
// port 3000. It serves the content directory at the URL *root*, so content
|
||||||
|
// assets are requested as root-relative paths (e.g. `/adventures/map.png`),
|
||||||
|
// not under `/content`.
|
||||||
|
const CLI_TARGET = "http://localhost:3000";
|
||||||
|
const CONTENT_DIR = resolve(process.cwd(), "content");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a request targets a real file inside the content directory.
|
||||||
|
*
|
||||||
|
* Mirrors the CLI server's static handling: only files are served, so a
|
||||||
|
* directory path falls through to the SPA fallback. Requests for content
|
||||||
|
* assets must be proxied to the CLI; otherwise they reach
|
||||||
|
* `historyApiFallback` and the browser receives `index.html` instead of the
|
||||||
|
* asset (which is why images failed to render in dev).
|
||||||
|
*/
|
||||||
|
function isContentFile(pathname: string): boolean {
|
||||||
|
// The web dev server owns its own bundled assets under /static/.
|
||||||
|
if (pathname.startsWith("/static/")) return false;
|
||||||
|
|
||||||
|
let relativePath: string;
|
||||||
|
try {
|
||||||
|
relativePath = decodeURIComponent(pathname).replace(/^\/+/, "");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!relativePath) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return statSync(join(CONTENT_DIR, relativePath)).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -48,13 +85,28 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
// Dev web server runs on its own port; the CLI content server keeps
|
||||||
// Proxy content API requests to the CLI dev server (npm run tttk)
|
// port 3000 (see the proxy targets below).
|
||||||
"/__CONTENT_INDEX.json": "http://localhost:3000",
|
port: 5173,
|
||||||
"/__COMPLETIONS.json": "http://localhost:3000",
|
proxy: [
|
||||||
"/content": "http://localhost:3000",
|
{
|
||||||
"/.ttrpg": "http://localhost:3000",
|
// Content API requests served by the CLI dev server (npm run dev:cli).
|
||||||
|
context: [
|
||||||
|
"/__CONTENT_INDEX.json",
|
||||||
|
"/__COMPLETIONS.json",
|
||||||
|
"/__CONTENT_REGISTRY.json",
|
||||||
|
"/.ttrpg",
|
||||||
|
],
|
||||||
|
target: CLI_TARGET,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Content assets (images, css, csv, …) live at the URL root on the CLI
|
||||||
|
// server, so forward any request that maps to a real file under
|
||||||
|
// content/ instead of letting it hit the SPA fallback.
|
||||||
|
context: (pathname: string) => isContentFile(pathname),
|
||||||
|
target: CLI_TARGET,
|
||||||
|
},
|
||||||
|
],
|
||||||
historyApiFallback: true,
|
historyApiFallback: true,
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Dev runner: starts the CLI content server (port 3000) and the rsbuild web
|
||||||
|
* dev server (port 5173) together, then tears both down when either exits.
|
||||||
|
*
|
||||||
|
* npm run dev # both (this script)
|
||||||
|
* npm run dev:cli # CLI content server only
|
||||||
|
* npm run dev:web # web dev server only
|
||||||
|
*/
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
const isWindows = process.platform === "win32";
|
||||||
|
const children = new Set();
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
/** Terminate a child and everything it spawned. */
|
||||||
|
function killTree(child) {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||||
|
if (isWindows) {
|
||||||
|
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// Negative pid targets the process group created via `detached`.
|
||||||
|
process.kill(-child.pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shutdown(code) {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
for (const child of children) killTree(child);
|
||||||
|
process.exitCode = code ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const script of ["dev:cli", "dev:web"]) {
|
||||||
|
const child = spawn("npm", ["run", script], {
|
||||||
|
stdio: "inherit",
|
||||||
|
shell: true,
|
||||||
|
detached: !isWindows,
|
||||||
|
});
|
||||||
|
children.add(child);
|
||||||
|
child.on("exit", (code) => shutdown(code ?? 0));
|
||||||
|
child.on("error", (error) => {
|
||||||
|
console.error(`[dev] Failed to start ${script}: ${error.message}`);
|
||||||
|
shutdown(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", () => shutdown(0));
|
||||||
|
process.on("SIGTERM", () => shutdown(0));
|
||||||
+35
-5
@@ -7,6 +7,13 @@ import {
|
|||||||
createSignal,
|
createSignal,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
|
import {
|
||||||
|
TbOutlineMenu2,
|
||||||
|
TbOutlineClipboardList,
|
||||||
|
TbOutlineHelpCircle,
|
||||||
|
TbOutlineLayoutSidebarLeftCollapse,
|
||||||
|
TbOutlineLayoutSidebarLeftExpand,
|
||||||
|
} from "solid-icons/tb";
|
||||||
import { useJournalStream } from "./components/stores/journalStream";
|
import { useJournalStream } from "./components/stores/journalStream";
|
||||||
|
|
||||||
// 导入组件以注册自定义元素
|
// 导入组件以注册自定义元素
|
||||||
@@ -46,6 +53,7 @@ const App: Component = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
||||||
|
const [isDesktopSidebarOpen, setIsDesktopSidebarOpen] = createSignal(true);
|
||||||
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
||||||
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
||||||
const [isJournalOpen, setIsJournalOpen] = createSignal(false);
|
const [isJournalOpen, setIsJournalOpen] = createSignal(false);
|
||||||
@@ -93,13 +101,25 @@ const App: Component = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header class="shrink-0 h-16 bg-white shadow z-30">
|
<header class="shrink-0 h-16 bg-white shadow z-30">
|
||||||
<div class="h-full max-w-4xl mx-auto px-4 flex items-center gap-4">
|
<div class="h-full max-w-4xl mx-auto px-4 flex items-center gap-4">
|
||||||
{/* 仅在移动端显示菜单按钮 */}
|
{/* 移动端:打开抽屉式侧边栏 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSidebarOpen(true)}
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
class="md:hidden text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
|
class="md:hidden text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
|
||||||
title="目录"
|
title="目录"
|
||||||
>
|
>
|
||||||
☰
|
<TbOutlineMenu2 class="inline-block align-[-0.125em]" />
|
||||||
|
</button>
|
||||||
|
{/* 桌面端:折叠/展开固定侧边栏 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDesktopSidebarOpen((v) => !v)}
|
||||||
|
class="hidden md:block text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
|
||||||
|
title={isDesktopSidebarOpen() ? "收起目录" : "展开目录"}
|
||||||
|
>
|
||||||
|
{isDesktopSidebarOpen() ? (
|
||||||
|
<TbOutlineLayoutSidebarLeftCollapse class="inline-block align-[-0.125em]" />
|
||||||
|
) : (
|
||||||
|
<TbOutlineLayoutSidebarLeftExpand class="inline-block align-[-0.125em]" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Tabletop Toolkit</h1>
|
<h1 class="text-2xl font-bold text-gray-900">Tabletop Toolkit</h1>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
@@ -122,14 +142,14 @@ const App: Component = () => {
|
|||||||
"bg-red-500": stream.connectionStatus === "error",
|
"bg-red-500": stream.connectionStatus === "error",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
📋
|
<TbOutlineClipboardList size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsDocOpen(true)}
|
onClick={() => setIsDocOpen(true)}
|
||||||
class="w-8 h-8 rounded-full border border-gray-300 text-gray-500 hover:text-gray-700 hover:border-gray-400 flex items-center justify-center text-sm font-medium"
|
class="w-8 h-8 rounded-full border border-gray-300 text-gray-500 hover:text-gray-700 hover:border-gray-400 flex items-center justify-center text-sm font-medium"
|
||||||
title="指令文档"
|
title="指令文档"
|
||||||
>
|
>
|
||||||
?
|
<TbOutlineHelpCircle size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -145,12 +165,22 @@ const App: Component = () => {
|
|||||||
|
|
||||||
{/* Body: sidebar + main + journal */}
|
{/* Body: sidebar + main + journal */}
|
||||||
<div class="flex flex-1 min-h-0">
|
<div class="flex flex-1 min-h-0">
|
||||||
{/* 桌面端固定侧边栏 (flex child) */}
|
{/* 桌面端侧边栏 — flex child,可折叠 */}
|
||||||
|
<div
|
||||||
|
class="hidden md:block md:shrink-0 md:overflow-hidden md:transition-all md:duration-300"
|
||||||
|
classList={{
|
||||||
|
"md:w-64": isDesktopSidebarOpen(),
|
||||||
|
"md:w-0": !isDesktopSidebarOpen(),
|
||||||
|
}}
|
||||||
|
inert={!isDesktopSidebarOpen()}
|
||||||
|
>
|
||||||
<DesktopSidebar
|
<DesktopSidebar
|
||||||
fileTree={fileTree()}
|
fileTree={fileTree()}
|
||||||
pathHeadings={pathHeadings()}
|
pathHeadings={pathHeadings()}
|
||||||
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||||
|
onClose={() => setIsDesktopSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main content — the scrollable region */}
|
{/* Main content — the scrollable region */}
|
||||||
<ScrollContainerCtx.Provider value={() => mainRef}>
|
<ScrollContainerCtx.Provider value={() => mainRef}>
|
||||||
|
|||||||
@@ -22,5 +22,9 @@ export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js"
|
|||||||
// Regex
|
// Regex
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Matches fenced code blocks with an info string (at least one attr). */
|
/**
|
||||||
export const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm;
|
* Matches fenced code blocks with an info string (at least one attr).
|
||||||
|
* Supports both backtick and tilde fences; the closing fence reuses the
|
||||||
|
* opening marker character and must be at least as long.
|
||||||
|
*/
|
||||||
|
export const FENCED_BLOCK_RE = /^([`~])\1{2,}\s*(\w*)\s+(\S.*?)\s*\n([\s\S]*?)\1{3,}\s*$/gm;
|
||||||
@@ -1,21 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* Declare parser — parses ```csv role=declare code blocks from markdown.
|
* Declare parser — parses ```yaml role=declare code blocks from markdown.
|
||||||
*
|
*
|
||||||
* Shared between CLI completions scanner and browser-side completions.
|
* Shared between the CLI scanner/validator and the browser-side journal.
|
||||||
*
|
*
|
||||||
* Format (columns can be in any order; `tag` and `threshold` are optional):
|
* The body is YAML with two optional maps (`declareSchema` below):
|
||||||
* tag,threshold,key,expr
|
|
||||||
* ,,$hp,$con*5+$mod_hp ← variable declaration
|
|
||||||
* ,,$ac,10+$dex ← variable declaration
|
|
||||||
* #warrior,2,$mod_hp,20 ← tag modifier (threshold 2)
|
|
||||||
* #warrior,,$mod_str,1 ← tag modifier (threshold defaults to 1)
|
|
||||||
*
|
*
|
||||||
* When `tag` is empty: $key is a reactively computed variable.
|
* vars:
|
||||||
* When `tag` is present: when #tag met (source's tagmap value >= threshold),
|
* $hp: $con*5 + $mod_hp
|
||||||
* $key gets expr added to its base.
|
* $ac: 10 + $dex
|
||||||
|
* tags:
|
||||||
|
* warrior:
|
||||||
|
* 1:
|
||||||
|
* $mod_hp: 20
|
||||||
|
* 2:
|
||||||
|
* $mod_str: 1
|
||||||
|
*
|
||||||
|
* `vars` entries are reactively computed variables. `tags` entries are
|
||||||
|
* modifiers: when a source variable's tag map holds `#warrior` at a count
|
||||||
|
* >= the threshold key, each `$target` gains the modifier value. Tag names
|
||||||
|
* may be written with or without the leading `#`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parse } from "csv-parse/browser/esm/sync";
|
import yaml from "js-yaml";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -38,65 +45,134 @@ export interface DeclareResult {
|
|||||||
tagModifiers: TagModifier[];
|
tagModifiers: TagModifier[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schema
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A `$name` variable key. */
|
||||||
|
const VAR_NAME = /^\$[A-Za-z_][A-Za-z0-9_]*$/;
|
||||||
|
/** A tag name, optionally carrying its leading `#`. */
|
||||||
|
const TAG_NAME = /^#?[A-Za-z_][A-Za-z0-9_-]*$/;
|
||||||
|
/** Threshold map keys are positive integers (1, 2, 3, …). */
|
||||||
|
const THRESHOLD = /^[1-9][0-9]*$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a single ```csv role=declare block body.
|
* Expression text. YAML resolves bare numbers to `number`, so both strings and
|
||||||
|
* numbers are accepted and normalized to a trimmed string. A missing value
|
||||||
|
* loads as `null` and becomes empty text, which the refine rejects. Anything
|
||||||
|
* else (boolean, date) is a mistake in the source. Unknown references inside
|
||||||
|
* the expression are resolved by the reactivity engine, not here.
|
||||||
*/
|
*/
|
||||||
export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
const exprSchema = z
|
||||||
const trimmed = csv.trim();
|
.union([z.string(), z.number(), z.null()])
|
||||||
|
.transform((value) => (value === null ? "" : String(value).trim()))
|
||||||
|
.refine((value) => value.length > 0, "expression must not be empty");
|
||||||
|
|
||||||
|
/** A map whose keys must look like `$name`. */
|
||||||
|
function varMap(message: string) {
|
||||||
|
return z.record(z.string(), exprSchema).superRefine((obj, ctx) => {
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
if (!VAR_NAME.test(key)) {
|
||||||
|
ctx.addIssue({ code: "custom", message, path: [key] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `threshold -> { $target: expression }`. */
|
||||||
|
const thresholdMap = z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
varMap("tag modifier targets must start with $, e.g. `$mod_hp`"),
|
||||||
|
)
|
||||||
|
.superRefine((obj, ctx) => {
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
if (!THRESHOLD.test(key)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "threshold must be a positive integer",
|
||||||
|
path: [key],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The accepted `role=declare` shape. Exported for tests and reuse. */
|
||||||
|
export const declareSchema = z.strictObject({
|
||||||
|
vars: varMap("variable names must start with $, e.g. `$hp`").optional(),
|
||||||
|
tags: z
|
||||||
|
.record(z.string(), thresholdMap)
|
||||||
|
.superRefine((obj, ctx) => {
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
if (!TAG_NAME.test(key)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "tag names may not contain spaces or punctuation",
|
||||||
|
path: [key],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single ```yaml role=declare block body.
|
||||||
|
*
|
||||||
|
* Throws with a `path: message` summary when the body is not valid YAML or
|
||||||
|
* does not match the schema. Callers attach the file/line context.
|
||||||
|
*/
|
||||||
|
export function parseDeclare(body: string): DeclareResult {
|
||||||
|
const trimmed = body.trim();
|
||||||
if (!trimmed) return { variables: [], tagModifiers: [] };
|
if (!trimmed) return { variables: [], tagModifiers: [] };
|
||||||
|
|
||||||
// Validate that required columns exist (order-agnostic, tag is optional)
|
let data: unknown;
|
||||||
const firstLine = trimmed.split(/\r?\n/)[0];
|
try {
|
||||||
const headers = firstLine.split(",").map((h) => h.trim().toLowerCase());
|
data = yaml.load(trimmed);
|
||||||
if (!headers.includes("key") || !headers.includes("expr")) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${source}: role=declare blocks must have "key" and "expr" columns. Got: ${headers.join(",")}`,
|
`role=declare body is not valid YAML: ${errorText(error)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = parse(trimmed, {
|
const parsed = declareSchema.safeParse(data ?? {});
|
||||||
columns: true,
|
if (!parsed.success) {
|
||||||
trim: true,
|
throw new Error(`role=declare: ${formatIssues(parsed.error)}`);
|
||||||
skipEmptyLines: true,
|
}
|
||||||
}) as Array<{ tag?: string; threshold?: string; key: string; expr: string }>;
|
|
||||||
|
|
||||||
const variables: VarDeclaration[] = [];
|
const variables: VarDeclaration[] = [];
|
||||||
|
for (const [key, expression] of Object.entries(parsed.data.vars ?? {})) {
|
||||||
|
variables.push({ key, expression });
|
||||||
|
}
|
||||||
|
|
||||||
const tagModifiers: TagModifier[] = [];
|
const tagModifiers: TagModifier[] = [];
|
||||||
|
for (const [name, thresholds] of Object.entries(parsed.data.tags ?? {})) {
|
||||||
for (const row of records) {
|
const tag = name.startsWith("#") ? name : `#${name}`;
|
||||||
const tag = row.tag ?? "";
|
for (const [rawThreshold, targets] of Object.entries(thresholds)) {
|
||||||
const key = row.key ?? "";
|
const threshold = Number(rawThreshold);
|
||||||
const expr = row.expr ?? "";
|
for (const [target, expression] of Object.entries(targets)) {
|
||||||
|
tagModifiers.push({ tag, target, expression, threshold });
|
||||||
if (!key || !expr) {
|
|
||||||
console.warn(`${source}: skipping row with empty key or expr`);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tag) {
|
|
||||||
// Tag modifier
|
|
||||||
if (!tag.startsWith("#")) {
|
|
||||||
throw new Error(`${source}: tag must start with #, got "${tag}"`);
|
|
||||||
}
|
|
||||||
if (!key.startsWith("$")) {
|
|
||||||
throw new Error(`${source}: key must start with $, got "${key}"`);
|
|
||||||
}
|
|
||||||
const thresholdRaw = row.threshold?.trim() ?? "";
|
|
||||||
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
|
|
||||||
if (isNaN(threshold) || threshold < 1) {
|
|
||||||
throw new Error(
|
|
||||||
`${source}: threshold must be a positive integer, got "${row.threshold}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
tagModifiers.push({ tag, target: key, expression: expr, threshold });
|
|
||||||
} else {
|
|
||||||
// Variable declaration
|
|
||||||
if (!key.startsWith("$")) {
|
|
||||||
throw new Error(`${source}: key must start with $, got "${key}"`);
|
|
||||||
}
|
|
||||||
variables.push({ key, expression: expr });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { variables, tagModifiers };
|
return { variables, tagModifiers };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatIssues(error: z.ZodError): string {
|
||||||
|
return error.issues
|
||||||
|
.map((issue) =>
|
||||||
|
issue.path.length
|
||||||
|
? `${issue.path.join(".")}: ${issue.message}`
|
||||||
|
: issue.message,
|
||||||
|
)
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for the variable/tag system:
|
* Tests for the variable/tag system:
|
||||||
* - declare-parser (CSV parsing)
|
* - declare-parser (YAML parsing)
|
||||||
* - block-scanner (attribute parsing)
|
* - block-scanner (attribute parsing)
|
||||||
* - variable-expression (expression evaluation)
|
* - variable-expression (expression evaluation)
|
||||||
* - var-reactivity (dependency graph, cascade, tag activation)
|
* - var-reactivity (dependency graph, cascade, tag activation)
|
||||||
@@ -27,7 +27,7 @@ jest.mock("github-slugger", () => {
|
|||||||
// Imports
|
// Imports
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import { parseDeclareCsv } from "./declare-parser";
|
import { parseDeclare } from "./declare-parser";
|
||||||
import { parseBlockAttrs } from "./block-scanner";
|
import { parseBlockAttrs } from "./block-scanner";
|
||||||
import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
|
import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +39,9 @@ import {
|
|||||||
getMods,
|
getMods,
|
||||||
getDeclExpr,
|
getDeclExpr,
|
||||||
extractDependencies,
|
extractDependencies,
|
||||||
|
rebuildReactivityFromStore,
|
||||||
|
getReactivityDiagnostics,
|
||||||
|
resolveVariable,
|
||||||
} from "../../components/journal/var-reactivity";
|
} from "../../components/journal/var-reactivity";
|
||||||
import { parseInput } from "../../components/journal/command-parser";
|
import { parseInput } from "../../components/journal/command-parser";
|
||||||
import {
|
import {
|
||||||
@@ -60,154 +63,154 @@ function store(entries: Record<string, string> = {}): Record<string, string> {
|
|||||||
// declare-parser
|
// declare-parser
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("parseDeclareCsv", () => {
|
describe("parseDeclare", () => {
|
||||||
test("parses variable declarations", () => {
|
test("parses variable declarations", () => {
|
||||||
const csv = `tag,key,expr
|
const result = parseDeclare(`
|
||||||
,$hp,$con*5+$mod_hp
|
vars:
|
||||||
,$ac,10+$dex`;
|
$hp: $con*5 + $mod_hp
|
||||||
const result = parseDeclareCsv(csv, "test.md");
|
$ac: 10 + $dex
|
||||||
expect(result.variables).toHaveLength(2);
|
`);
|
||||||
expect(result.variables[0]).toEqual({
|
expect(result.variables).toEqual([
|
||||||
key: "$hp",
|
{ key: "$hp", expression: "$con*5 + $mod_hp" },
|
||||||
expression: "$con*5+$mod_hp",
|
{ key: "$ac", expression: "10 + $dex" },
|
||||||
});
|
]);
|
||||||
expect(result.variables[1]).toEqual({ key: "$ac", expression: "10+$dex" });
|
|
||||||
expect(result.tagModifiers).toHaveLength(0);
|
expect(result.tagModifiers).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses tag modifiers", () => {
|
test("parses tag modifiers with thresholds", () => {
|
||||||
const csv = `tag,key,expr
|
const result = parseDeclare(`
|
||||||
#warrior,$mod_hp,20
|
tags:
|
||||||
#mage,$mod_mp,15`;
|
warrior:
|
||||||
const result = parseDeclareCsv(csv, "test.md");
|
2:
|
||||||
|
$mod_hp: 20
|
||||||
|
mage:
|
||||||
|
1:
|
||||||
|
$mod_mp: 15
|
||||||
|
`);
|
||||||
expect(result.variables).toHaveLength(0);
|
expect(result.variables).toHaveLength(0);
|
||||||
expect(result.tagModifiers).toHaveLength(2);
|
expect(result.tagModifiers).toEqual([
|
||||||
|
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 },
|
||||||
|
{ tag: "#mage", target: "$mod_mp", expression: "15", threshold: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple thresholds for one tag stack in ascending order", () => {
|
||||||
|
const result = parseDeclare(`
|
||||||
|
tags:
|
||||||
|
warrior:
|
||||||
|
3:
|
||||||
|
$mod_hp: 20
|
||||||
|
1:
|
||||||
|
$mod_hp: 10
|
||||||
|
`);
|
||||||
|
expect(result.tagModifiers).toEqual([
|
||||||
|
{ tag: "#warrior", target: "$mod_hp", expression: "10", threshold: 1 },
|
||||||
|
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one threshold can carry multiple targets", () => {
|
||||||
|
const result = parseDeclare(`
|
||||||
|
tags:
|
||||||
|
warrior:
|
||||||
|
1:
|
||||||
|
$mod_hp: 20
|
||||||
|
$mod_str: 1
|
||||||
|
`);
|
||||||
|
expect(result.tagModifiers).toEqual([
|
||||||
|
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
||||||
|
{ tag: "#warrior", target: "$mod_str", expression: "1", threshold: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a tag name written with #", () => {
|
||||||
|
const result = parseDeclare('tags:\n "#warrior":\n 1:\n $mod_hp: 20\n');
|
||||||
expect(result.tagModifiers[0]).toEqual({
|
expect(result.tagModifiers[0]).toEqual({
|
||||||
tag: "#warrior",
|
tag: "#warrior",
|
||||||
target: "$mod_hp",
|
target: "$mod_hp",
|
||||||
expression: "20",
|
expression: "20",
|
||||||
threshold: 1,
|
threshold: 1,
|
||||||
});
|
});
|
||||||
expect(result.tagModifiers[1]).toEqual({
|
|
||||||
tag: "#mage",
|
|
||||||
target: "$mod_mp",
|
|
||||||
expression: "15",
|
|
||||||
threshold: 1,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses tag modifiers with threshold", () => {
|
test("normalizes numeric values to expression text", () => {
|
||||||
const csv = `tag,threshold,key,expr
|
const result = parseDeclare("vars:\n $con: 10\n $dex: 12\n");
|
||||||
#warrior,2,$mod_hp,20
|
expect(result.variables).toEqual([
|
||||||
#mage,,$mod_mp,15`;
|
{ key: "$con", expression: "10" },
|
||||||
const result = parseDeclareCsv(csv, "test.md");
|
{ key: "$dex", expression: "12" },
|
||||||
expect(result.tagModifiers).toHaveLength(2);
|
]);
|
||||||
expect(result.tagModifiers[0]).toEqual({
|
|
||||||
tag: "#warrior",
|
|
||||||
target: "$mod_hp",
|
|
||||||
expression: "20",
|
|
||||||
threshold: 2,
|
|
||||||
});
|
|
||||||
expect(result.tagModifiers[1]).toEqual({
|
|
||||||
tag: "#mage",
|
|
||||||
target: "$mod_mp",
|
|
||||||
expression: "15",
|
|
||||||
threshold: 1,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects invalid threshold", () => {
|
test("YAML numeric forms are resolved before stringification", () => {
|
||||||
const csv = `tag,threshold,key,expr
|
const result = parseDeclare("vars:\n $a: 0x1F\n $b: 1e3\n");
|
||||||
#warrior,0,$mod_hp,20`;
|
expect(result.variables).toEqual([
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
{ key: "$a", expression: "31" },
|
||||||
"threshold must be a positive integer",
|
{ key: "$b", expression: "1000" },
|
||||||
);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses mixed declarations and tag modifiers", () => {
|
test("parses mixed declarations and tag modifiers", () => {
|
||||||
const csv = `tag,key,expr
|
const result = parseDeclare(`
|
||||||
,$hp,$con*5
|
vars:
|
||||||
#warrior,$mod_hp,20
|
$hp: $con*5
|
||||||
,$ac,10+$dex`;
|
$ac: 10+$dex
|
||||||
const result = parseDeclareCsv(csv, "test.md");
|
tags:
|
||||||
|
warrior:
|
||||||
|
1:
|
||||||
|
$mod_hp: 20
|
||||||
|
`);
|
||||||
expect(result.variables).toHaveLength(2);
|
expect(result.variables).toHaveLength(2);
|
||||||
expect(result.tagModifiers).toHaveLength(1);
|
expect(result.tagModifiers).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects missing key column", () => {
|
test("handles empty, whitespace, and comment-only bodies", () => {
|
||||||
const csv = `tag,expr
|
for (const body of ["", " \n ", "# just a comment\n"]) {
|
||||||
,$con*5`;
|
expect(parseDeclare(body)).toEqual({ variables: [], tagModifiers: [] });
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
}
|
||||||
'role=declare blocks must have "key" and "expr" columns',
|
});
|
||||||
|
|
||||||
|
test("rejects invalid YAML", () => {
|
||||||
|
expect(() => parseDeclare("vars: [unclosed\n")).toThrow("not valid YAML");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects duplicate keys", () => {
|
||||||
|
expect(() => parseDeclare("vars:\n $hp: 1\n $hp: 2\n")).toThrow(
|
||||||
|
"duplicated mapping key",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects missing expr column", () => {
|
test("rejects unknown top-level keys", () => {
|
||||||
const csv = `tag,key
|
expect(() => parseDeclare("variable:\n $hp: 1\n")).toThrow(
|
||||||
,$hp`;
|
"Unrecognized key",
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
|
||||||
'role=declare blocks must have "key" and "expr" columns',
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects tag without # prefix", () => {
|
test("rejects a variable name without $", () => {
|
||||||
const csv = `tag,key,expr
|
expect(() => parseDeclare("vars:\n hp: 1\n")).toThrow(
|
||||||
warrior,$mod_hp,20`;
|
"variable names must start with $",
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
|
||||||
"tag must start with #",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects key without $ prefix in declaration", () => {
|
test("rejects a tag modifier target without $", () => {
|
||||||
const csv = `tag,key,expr
|
expect(() =>
|
||||||
,hp,$con*5`;
|
parseDeclare("tags:\n warrior:\n 1:\n mod_hp: 20\n"),
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
).toThrow("tag modifier targets must start with $");
|
||||||
"key must start with $",
|
});
|
||||||
|
|
||||||
|
test("rejects a threshold that is not a positive integer", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseDeclare("tags:\n warrior:\n 0:\n $mod_hp: 20\n"),
|
||||||
|
).toThrow("threshold must be a positive integer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an empty expression", () => {
|
||||||
|
expect(() => parseDeclare("vars:\n $hp:\n")).toThrow(
|
||||||
|
"expression must not be empty",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects key without $ prefix in tag modifier", () => {
|
test("rejects non-numeric, non-string values", () => {
|
||||||
const csv = `tag,key,expr
|
expect(() => parseDeclare("vars:\n $hp: true\n")).toThrow();
|
||||||
#warrior,mod_hp,20`;
|
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
|
||||||
"key must start with $",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("skips rows with empty key or expr (warns, doesn't throw)", () => {
|
|
||||||
const csv = `tag,key,expr
|
|
||||||
,$hp,$con*5
|
|
||||||
,,`;
|
|
||||||
const result = parseDeclareCsv(csv, "test.md");
|
|
||||||
expect(result.variables).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles empty CSV", () => {
|
|
||||||
const result = parseDeclareCsv("", "test.md");
|
|
||||||
expect(result.variables).toHaveLength(0);
|
|
||||||
expect(result.tagModifiers).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles whitespace-only CSV", () => {
|
|
||||||
const result = parseDeclareCsv(" \n ", "test.md");
|
|
||||||
expect(result.variables).toHaveLength(0);
|
|
||||||
expect(result.tagModifiers).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("columns can be in any order", () => {
|
|
||||||
const csv = `expr,key,tag
|
|
||||||
$con*5,$hp,
|
|
||||||
20,$mod_hp,#warrior`;
|
|
||||||
const result = parseDeclareCsv(csv, "test.md");
|
|
||||||
expect(result.variables).toHaveLength(1);
|
|
||||||
expect(result.variables[0]).toEqual({ key: "$hp", expression: "$con*5" });
|
|
||||||
expect(result.tagModifiers).toHaveLength(1);
|
|
||||||
expect(result.tagModifiers[0]).toEqual({
|
|
||||||
tag: "#warrior",
|
|
||||||
target: "$mod_hp",
|
|
||||||
expression: "20",
|
|
||||||
threshold: 1,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,16 +229,26 @@ describe("parseBlockAttrs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("parses quoted values", () => {
|
test("parses quoted values", () => {
|
||||||
const attrs = parseBlockAttrs('csv id="my stats" role=declare');
|
const attrs = parseBlockAttrs('yaml id="my stats" role=declare');
|
||||||
expect(attrs.id).toBe("my stats");
|
expect(attrs.id).toBe("my stats");
|
||||||
expect(attrs.role).toBe("declare");
|
expect(attrs.role).toBe("declare");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("collects unknown attributes in extra", () => {
|
test("collects unknown attributes in extra", () => {
|
||||||
const attrs = parseBlockAttrs("csv role=declare foo=bar baz=42");
|
const attrs = parseBlockAttrs("yaml role=declare foo=bar baz=42");
|
||||||
expect(attrs.extra).toEqual({ foo: "bar", baz: "42" });
|
expect(attrs.extra).toEqual({ foo: "bar", baz: "42" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps hyphenated attribute names intact", () => {
|
||||||
|
const attrs = parseBlockAttrs("yaml role=tag data-config=foo data-src=./x");
|
||||||
|
expect(attrs.extra).toEqual({ "data-config": "foo", "data-src": "./x" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps hyphenated attribute names with quoted values", () => {
|
||||||
|
const attrs = parseBlockAttrs('yaml role=tag data-config="my config"');
|
||||||
|
expect(attrs.extra).toEqual({ "data-config": "my config" });
|
||||||
|
});
|
||||||
|
|
||||||
test("handles empty info string", () => {
|
test("handles empty info string", () => {
|
||||||
const attrs = parseBlockAttrs("");
|
const attrs = parseBlockAttrs("");
|
||||||
expect(attrs.lang).toBe("");
|
expect(attrs.lang).toBe("");
|
||||||
@@ -271,32 +284,31 @@ describe("evaluateExpression", () => {
|
|||||||
test("evaluates variable references", () => {
|
test("evaluates variable references", () => {
|
||||||
const result = evaluateExpression("$con * 5 + $mod_hp", {
|
const result = evaluateExpression("$con * 5 + $mod_hp", {
|
||||||
lookup: (name) => {
|
lookup: (name) => {
|
||||||
if (name === "con") return "12";
|
if (name === "$con") return "12";
|
||||||
if (name === "mod_hp") return "20";
|
if (name === "$mod_hp") return "20";
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.value).toEqual({ kind: "number", value: 80 }); // 12*5 + 20
|
expect(result.value).toEqual({ kind: "number", value: 80 }); // 12*5 + 20
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns 0 for undefined variables", () => {
|
test("throws on unknown variables", () => {
|
||||||
const result = evaluateExpression("$unknown + 5", {
|
expect(() =>
|
||||||
lookup: () => undefined,
|
evaluateExpression("$unknown + 5", { lookup: () => undefined }),
|
||||||
});
|
).toThrow("Unknown variable: $unknown");
|
||||||
expect(result.value).toEqual({ kind: "number", value: 5 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws on type mismatch (tagmap + number)", () => {
|
test("throws on type mismatch (tagmap + number)", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
evaluateExpression("$class + 5", {
|
evaluateExpression("$class + 5", {
|
||||||
lookup: (name) => (name === "class" ? "#warrior" : undefined),
|
lookup: (name) => (name === "$class" ? "#warrior" : undefined),
|
||||||
}),
|
}),
|
||||||
).toThrow("Type mismatch");
|
).toThrow("Type mismatch");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolves tagmap variable without arithmetic", () => {
|
test("resolves tagmap variable without arithmetic", () => {
|
||||||
const result = evaluateExpression("$class", {
|
const result = evaluateExpression("$class", {
|
||||||
lookup: (name) => (name === "class" ? "#warrior:1" : undefined),
|
lookup: (name) => (name === "$class" ? "#warrior:1" : undefined),
|
||||||
});
|
});
|
||||||
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } });
|
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } });
|
||||||
});
|
});
|
||||||
@@ -377,6 +389,20 @@ describe("evaluateExpression", () => {
|
|||||||
expect(result.value.value).toBeLessThanOrEqual(23);
|
expect(result.value.value).toBeLessThanOrEqual(23);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("evaluates bare dice notation", () => {
|
||||||
|
const result = evaluateExpression("d20", { lookup: () => undefined });
|
||||||
|
expect(result.value.kind).toBe("number");
|
||||||
|
expect(result.value.value).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(result.value.value).toBeLessThanOrEqual(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evaluates bare dice alongside modifiers", () => {
|
||||||
|
const result = evaluateExpression("d6 + 5", { lookup: () => undefined });
|
||||||
|
expect(result.value.kind).toBe("number");
|
||||||
|
expect(result.value.value).toBeGreaterThanOrEqual(6);
|
||||||
|
expect(result.value.value).toBeLessThanOrEqual(11);
|
||||||
|
});
|
||||||
|
|
||||||
test("throws on division by zero", () => {
|
test("throws on division by zero", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
evaluateExpression("10 / 0", { lookup: () => undefined }),
|
evaluateExpression("10 / 0", { lookup: () => undefined }),
|
||||||
@@ -395,6 +421,15 @@ describe("evaluateExpression", () => {
|
|||||||
).toThrow("Unexpected end");
|
).toThrow("Unexpected end");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("throws on malformed number literals", () => {
|
||||||
|
expect(() =>
|
||||||
|
evaluateExpression("1.2.3", { lookup: () => undefined }),
|
||||||
|
).toThrow("Invalid number literal");
|
||||||
|
expect(() =>
|
||||||
|
evaluateExpression("1.", { lookup: () => undefined }),
|
||||||
|
).toThrow("Invalid number literal");
|
||||||
|
});
|
||||||
|
|
||||||
test("handles decimal numbers", () => {
|
test("handles decimal numbers", () => {
|
||||||
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined });
|
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined });
|
||||||
expect(result.value).toEqual({ kind: "number", value: 6 });
|
expect(result.value).toEqual({ kind: "number", value: 6 });
|
||||||
@@ -411,8 +446,8 @@ describe("evaluateExpression", () => {
|
|||||||
test("complex expression with variables and functions", () => {
|
test("complex expression with variables and functions", () => {
|
||||||
const result = evaluateExpression("floor($con / 2) + $mod", {
|
const result = evaluateExpression("floor($con / 2) + $mod", {
|
||||||
lookup: (name) => {
|
lookup: (name) => {
|
||||||
if (name === "con") return "15";
|
if (name === "$con") return "15";
|
||||||
if (name === "mod") return "3";
|
if (name === "$mod") return "3";
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -506,6 +541,33 @@ describe("var-reactivity", () => {
|
|||||||
}),
|
}),
|
||||||
).not.toThrow();
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps the previous configuration when a re-init fails", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
initReactivity({
|
||||||
|
declarations: [{ key: "$hp", expression: "10 + 5" }],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
expect(computeInitialValues(store()).find((r) => r.key === "$hp")?.value).toBe(
|
||||||
|
"15",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
initReactivity({
|
||||||
|
declarations: [
|
||||||
|
{ key: "$a", expression: "$b + 1" },
|
||||||
|
{ key: "$b", expression: "$a + 1" },
|
||||||
|
],
|
||||||
|
tagModifiers: [],
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
|
||||||
|
// The failed re-init did not clobber the working configuration.
|
||||||
|
const results = computeInitialValues(store());
|
||||||
|
expect(results.find((r) => r.key === "$hp")?.value).toBe("15");
|
||||||
|
expect(results.find((r) => r.key === "$a")).toBeUndefined();
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("computeInitialValues", () => {
|
describe("computeInitialValues", () => {
|
||||||
@@ -539,6 +601,33 @@ describe("var-reactivity", () => {
|
|||||||
expect(hpResult?.value).toBe("60"); // 12 * 5
|
expect(hpResult?.value).toBe("60"); // 12 * 5
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reports the base alongside the combined value", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [{ key: "$hp", expression: "$con * 5" }],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = computeInitialValues({ $con: "12" });
|
||||||
|
const hp = results.find((r) => r.key === "$hp");
|
||||||
|
expect(hp?.value).toBe("60");
|
||||||
|
expect(hp?.base).toBe("60");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips variables already present in the store", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [
|
||||||
|
{ key: "$hp", expression: "10 + 5" },
|
||||||
|
{ key: "$ac", expression: "15" },
|
||||||
|
],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = computeInitialValues({ $hp: "99" }, new Set(["$hp"]));
|
||||||
|
// Existing value is left untouched; only missing keys are computed.
|
||||||
|
expect(results.find((r) => r.key === "$hp")).toBeUndefined();
|
||||||
|
expect(results.find((r) => r.key === "$ac")?.value).toBe("15");
|
||||||
|
});
|
||||||
|
|
||||||
test("returns empty array when no declarations", () => {
|
test("returns empty array when no declarations", () => {
|
||||||
initReactivity({ declarations: [], tagModifiers: [] });
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
const results = computeInitialValues(store());
|
const results = computeInitialValues(store());
|
||||||
@@ -546,6 +635,113 @@ describe("var-reactivity", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("diagnostics", () => {
|
||||||
|
test("reports a declaration that fails to evaluate", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
initReactivity({
|
||||||
|
declarations: [
|
||||||
|
{ key: "$hp", expression: "10 + 5" },
|
||||||
|
{ key: "$bad", expression: "#warrior * 2" },
|
||||||
|
],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = computeInitialValues(store());
|
||||||
|
expect(results.find((r) => r.key === "$hp")?.value).toBe("15");
|
||||||
|
expect(results.find((r) => r.key === "$bad")).toBeUndefined();
|
||||||
|
|
||||||
|
const diags = getReactivityDiagnostics();
|
||||||
|
expect(diags).toHaveLength(1);
|
||||||
|
expect(diags[0].kind).toBe("declaration");
|
||||||
|
expect(diags[0].key).toBe("$bad");
|
||||||
|
expect(diags[0].expression).toBe("#warrior * 2");
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dedupes repeated failures of the same expression", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
initReactivity({
|
||||||
|
declarations: [{ key: "$bad", expression: "#warrior * 2" }],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
computeInitialValues(store());
|
||||||
|
computeInitialValues(store());
|
||||||
|
expect(getReactivityDiagnostics()).toHaveLength(1);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears diagnostics on re-init", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
initReactivity({
|
||||||
|
declarations: [{ key: "$bad", expression: "#warrior * 2" }],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
computeInitialValues(store());
|
||||||
|
expect(getReactivityDiagnostics()).toHaveLength(1);
|
||||||
|
|
||||||
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
|
expect(getReactivityDiagnostics()).toHaveLength(0);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records a diagnostic when declarations are circular", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
expect(() =>
|
||||||
|
initReactivity({
|
||||||
|
declarations: [
|
||||||
|
{ key: "$a", expression: "$b + 1" },
|
||||||
|
{ key: "$b", expression: "$a + 1" },
|
||||||
|
],
|
||||||
|
tagModifiers: [],
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
|
||||||
|
const diags = getReactivityDiagnostics();
|
||||||
|
expect(diags).toHaveLength(1);
|
||||||
|
expect(diags[0].kind).toBe("init");
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports a tag modifier that fails to evaluate", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
initReactivity({
|
||||||
|
declarations: [],
|
||||||
|
tagModifiers: [
|
||||||
|
{ tag: "#warrior", target: "$hp", expression: "#x * 2", threshold: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
setBase("$class", "#warrior:1");
|
||||||
|
computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
|
||||||
|
|
||||||
|
const diags = getReactivityDiagnostics();
|
||||||
|
expect(diags).toHaveLength(1);
|
||||||
|
expect(diags[0].kind).toBe("modifier");
|
||||||
|
expect(diags[0].key).toBe("$hp");
|
||||||
|
expect(diags[0].tag).toBe("#warrior");
|
||||||
|
expect(diags[0].source).toBe("$class");
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps cascading other dependents when a declaration fails", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
initReactivity({
|
||||||
|
declarations: [
|
||||||
|
{ key: "$bad", expression: "$con * #warrior" },
|
||||||
|
{ key: "$good", expression: "$con + 1" },
|
||||||
|
],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cascade = computeCascade("$con", undefined, store({ $con: "5" }));
|
||||||
|
expect(cascade.find((r) => r.key === "$good")?.value).toBe("6");
|
||||||
|
expect(cascade.find((r) => r.key === "$bad")).toBeUndefined();
|
||||||
|
expect(getReactivityDiagnostics().some((d) => d.key === "$bad")).toBe(true);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getCombined / setBase", () => {
|
describe("getCombined / setBase", () => {
|
||||||
test("returns base value when no mods", () => {
|
test("returns base value when no mods", () => {
|
||||||
initReactivity({ declarations: [], tagModifiers: [] });
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
@@ -570,6 +766,127 @@ describe("var-reactivity", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveVariable", () => {
|
||||||
|
test("returns undefined for unknown variables", () => {
|
||||||
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
|
expect(resolveVariable("$nope")).toBeUndefined();
|
||||||
|
expect(resolveVariable("$nope", {})).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a real 0 rather than undefined", () => {
|
||||||
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
|
setBase("$zero", "0");
|
||||||
|
expect(resolveVariable("$zero")).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves keys present only in the fallback store", () => {
|
||||||
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
|
expect(resolveVariable("$hp", { $hp: "12" })).toBe("12");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rebuildReactivityFromStore", () => {
|
||||||
|
test("restores bases exactly without double-counting modifiers", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [],
|
||||||
|
tagModifiers: [
|
||||||
|
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// `variables` holds combined values; `bases` holds what derived them.
|
||||||
|
const hydrated = { $class: "#warrior:1", $mod_hp: "20" };
|
||||||
|
const bases = { $class: "#warrior:1", $mod_hp: "0" };
|
||||||
|
rebuildReactivityFromStore(hydrated, bases);
|
||||||
|
|
||||||
|
expect(getCombined("$mod_hp", hydrated)).toBe("20");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recovers a non-zero base and keeps modifiers applied", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [],
|
||||||
|
tagModifiers: [
|
||||||
|
{ tag: "#warrior", target: "$hp", expression: "20", threshold: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const hydrated = { $class: "#warrior:1", $hp: "70" }; // base 50 + 20
|
||||||
|
const bases = { $class: "#warrior:1", $hp: "50" };
|
||||||
|
rebuildReactivityFromStore(hydrated, bases);
|
||||||
|
|
||||||
|
expect(getCombined("$hp", hydrated)).toBe("70");
|
||||||
|
|
||||||
|
// A later /set replaces the base; the modifier still applies.
|
||||||
|
setBase("$hp", "60");
|
||||||
|
expect(getCombined("$hp", hydrated)).toBe("80");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deactivates a hydrated modifier on a later tag change", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [],
|
||||||
|
tagModifiers: [
|
||||||
|
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const hydrated = { $class: "#warrior:1", $mod_hp: "20" };
|
||||||
|
const bases = { $class: "#warrior:1", $mod_hp: "0" };
|
||||||
|
rebuildReactivityFromStore(hydrated, bases);
|
||||||
|
|
||||||
|
setBase("$class", "#mage:1");
|
||||||
|
const cascade = computeCascade("$class", "#warrior:1", {
|
||||||
|
...hydrated,
|
||||||
|
$class: "#mage:1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cascade.find((r) => r.key === "$mod_hp")?.value).toBe("0");
|
||||||
|
expect(getCombined("$mod_hp", { $class: "#mage:1" })).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rebuilds a tagmap variable's base from its published base", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [],
|
||||||
|
tagModifiers: [
|
||||||
|
{ tag: "#warrior", target: "$pool", expression: "2", threshold: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// $pool is a tagmap variable that receives a +2 #warrior tagmap mod.
|
||||||
|
const hydrated = { $class: "#warrior:1", $pool: "#druid:3;#warrior:2" };
|
||||||
|
const bases = { $class: "#warrior:1", $pool: "#druid:3" };
|
||||||
|
rebuildReactivityFromStore(hydrated, bases);
|
||||||
|
|
||||||
|
expect(getCombined("$pool", hydrated)).toBe("#druid:3;#warrior:2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is idempotent when called twice", () => {
|
||||||
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
|
const hydrated = { $hp: "42" };
|
||||||
|
rebuildReactivityFromStore(hydrated, { $hp: "42" });
|
||||||
|
rebuildReactivityFromStore(hydrated, { $hp: "42" });
|
||||||
|
expect(getCombined("$hp", hydrated)).toBe("42");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeCascade — declarations", () => {
|
||||||
|
test("does not re-publish or re-evaluate declared dependencies that did not change", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [
|
||||||
|
{ key: "$con", expression: "12" },
|
||||||
|
{ key: "$hp", expression: "$con * 5" },
|
||||||
|
],
|
||||||
|
tagModifiers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// $con was set to 14; only its dependent $hp should be re-evaluated.
|
||||||
|
setBase("$con", "14");
|
||||||
|
const cascade = computeCascade("$con", "12", store({ $con: "14" }));
|
||||||
|
|
||||||
|
expect(cascade.map((r) => r.key)).toEqual(["$hp"]);
|
||||||
|
expect(cascade[0].value).toBe("70");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("computeCascade — tag activation/deactivation", () => {
|
describe("computeCascade — tag activation/deactivation", () => {
|
||||||
test("activates tag modifiers when a tag value is set", () => {
|
test("activates tag modifiers when a tag value is set", () => {
|
||||||
initReactivity({
|
initReactivity({
|
||||||
@@ -605,6 +922,27 @@ describe("var-reactivity", () => {
|
|||||||
expect(modStr?.value).toBe("5");
|
expect(modStr?.value).toBe("5");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reports the raw base alongside the combined value", () => {
|
||||||
|
initReactivity({
|
||||||
|
declarations: [],
|
||||||
|
tagModifiers: [
|
||||||
|
{ tag: "#warrior", target: "$hp", expression: "20", threshold: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
setBase("$hp", "50");
|
||||||
|
setBase("$class", "#warrior:1");
|
||||||
|
const cascade = computeCascade(
|
||||||
|
"$class",
|
||||||
|
undefined,
|
||||||
|
store({ $class: "#warrior:1" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hp = cascade.find((r) => r.key === "$hp");
|
||||||
|
expect(hp?.value).toBe("70");
|
||||||
|
expect(hp?.base).toBe("50");
|
||||||
|
});
|
||||||
|
|
||||||
test("deactivates tag modifiers when tag is removed from tagmap", () => {
|
test("deactivates tag modifiers when tag is removed from tagmap", () => {
|
||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
|
|||||||
@@ -29,6 +29,35 @@ describe("scanDoc", () => {
|
|||||||
expect(entry.role).toBe("spark-table");
|
expect(entry.role).toBe("spark-table");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("tilde-fenced role blocks are scanned", () => {
|
||||||
|
const md = [
|
||||||
|
"~~~csv role=spark-table",
|
||||||
|
"d6,Name",
|
||||||
|
"1,Alice",
|
||||||
|
"~~~",
|
||||||
|
].join("\n");
|
||||||
|
const { stripped, content } = scanDoc(md, "test.md");
|
||||||
|
|
||||||
|
expect(stripped).toMatch(/^:md-table\[\.\/csv_/);
|
||||||
|
const [entry] = Object.values(content);
|
||||||
|
expect(entry.body).toContain("d6,Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tilde-fenced declare blocks are scanned", () => {
|
||||||
|
const md = [
|
||||||
|
"~~~yaml role=declare",
|
||||||
|
"vars:",
|
||||||
|
" $hp: 10",
|
||||||
|
"~~~",
|
||||||
|
].join("\n");
|
||||||
|
const { stripped, content } = scanDoc(md, "test.md");
|
||||||
|
|
||||||
|
expect(stripped).toBe("");
|
||||||
|
const [entry] = Object.values(content);
|
||||||
|
expect(entry.kind).toBe("declare");
|
||||||
|
expect(entry.body).toContain("$hp: 10");
|
||||||
|
});
|
||||||
|
|
||||||
test("markdown role=spark-table body is converted to CSV", () => {
|
test("markdown role=spark-table body is converted to CSV", () => {
|
||||||
const md = [
|
const md = [
|
||||||
"```markdown role=spark-table",
|
"```markdown role=spark-table",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
import Slugger from "github-slugger";
|
import Slugger from "github-slugger";
|
||||||
import {
|
import {
|
||||||
parseDeclareCsv,
|
parseDeclare,
|
||||||
type VarDeclaration,
|
type VarDeclaration,
|
||||||
type TagModifier,
|
type TagModifier,
|
||||||
} from "./completions/declare-parser.js";
|
} from "./completions/declare-parser.js";
|
||||||
@@ -124,6 +124,7 @@ export function scanDoc(content: string, docPath: string): DocScanResult {
|
|||||||
FENCED_BLOCK_RE,
|
FENCED_BLOCK_RE,
|
||||||
(
|
(
|
||||||
match: string,
|
match: string,
|
||||||
|
_marker: string,
|
||||||
lang: string,
|
lang: string,
|
||||||
infoString: string,
|
infoString: string,
|
||||||
body: string,
|
body: string,
|
||||||
@@ -471,7 +472,7 @@ export function deriveBlocks(registry: ContentRegistry): {
|
|||||||
for (const entry of Object.values(store)) {
|
for (const entry of Object.values(store)) {
|
||||||
if (entry.kind !== "declare") continue;
|
if (entry.kind !== "declare") continue;
|
||||||
try {
|
try {
|
||||||
const result = parseDeclareCsv(entry.body, docPath);
|
const result = parseDeclare(entry.body);
|
||||||
declarations.push(...result.variables);
|
declarations.push(...result.variables);
|
||||||
tagModifiers.push(...result.tagModifiers);
|
tagModifiers.push(...result.tagModifiers);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import {
|
||||||
|
appendStream,
|
||||||
|
extractSessionId,
|
||||||
|
handleMetaChange,
|
||||||
|
metaPath,
|
||||||
|
migrateLegacyManifest,
|
||||||
|
repairStreamTail,
|
||||||
|
scanSessions,
|
||||||
|
sessionDir,
|
||||||
|
streamPath,
|
||||||
|
writeFileAtomic,
|
||||||
|
type SessionMeta,
|
||||||
|
} from "./journal";
|
||||||
|
|
||||||
|
function makeRoot(): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), "ttrpg-journal-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(root: string): void {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("writeFileAtomic", () => {
|
||||||
|
test("writes content and leaves no temp file behind", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const p = join(root, "meta.json");
|
||||||
|
writeFileAtomic(p, '{"a":1}');
|
||||||
|
expect(readFileSync(p, "utf-8")).toBe('{"a":1}');
|
||||||
|
expect(readdirSync(root)).toEqual(["meta.json"]);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("repairStreamTail", () => {
|
||||||
|
test("truncates a partial trailing line", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const p = join(root, "stream.jsonl");
|
||||||
|
writeFileSync(p, '{"a":1}\n{"b":2');
|
||||||
|
repairStreamTail(p);
|
||||||
|
expect(readFileSync(p, "utf-8")).toBe('{"a":1}\n');
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves a well-formed file untouched", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const p = join(root, "stream.jsonl");
|
||||||
|
writeFileSync(p, '{"a":1}\n');
|
||||||
|
repairStreamTail(p);
|
||||||
|
expect(readFileSync(p, "utf-8")).toBe('{"a":1}\n');
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scanSessions", () => {
|
||||||
|
test("adopts a directory that has a stream but no meta", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
mkdirSync(sessionDir(root, "default"), { recursive: true });
|
||||||
|
writeFileSync(streamPath(root, "default"), '{"id":"gm-1"}\n');
|
||||||
|
|
||||||
|
const index = scanSessions(root);
|
||||||
|
|
||||||
|
expect(index["default"]).toMatchObject({ name: "default", players: [] });
|
||||||
|
expect(existsSync(metaPath(root, "default"))).toBe(true);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quarantines corrupt meta and adopts the session", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const dir = sessionDir(root, "broken");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(metaPath(root, "broken"), "{ not json");
|
||||||
|
writeFileSync(streamPath(root, "broken"), '{"id":"gm-1"}\n');
|
||||||
|
|
||||||
|
const index = scanSessions(root);
|
||||||
|
|
||||||
|
expect(index["broken"]).toBeDefined();
|
||||||
|
const quarantined = readdirSync(dir).filter((f) =>
|
||||||
|
f.startsWith("meta.json.corrupt-"),
|
||||||
|
);
|
||||||
|
expect(quarantined).toHaveLength(1);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a valid session that has no stream yet", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
mkdirSync(sessionDir(root, "test"), { recursive: true });
|
||||||
|
writeFileAtomic(
|
||||||
|
metaPath(root, "test"),
|
||||||
|
JSON.stringify({ name: "test", created: 1, players: [] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const index = scanSessions(root);
|
||||||
|
|
||||||
|
expect(index["test"]).toEqual({ name: "test", created: 1, players: [] });
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("migrateLegacyManifest", () => {
|
||||||
|
test("splits a legacy manifest into per-session meta and renames it", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "manifest.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
sessions: {
|
||||||
|
aaa: { name: "aaa", created: 1, players: [] },
|
||||||
|
bbb: { name: "bbb", created: 2, players: [] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
migrateLegacyManifest(root);
|
||||||
|
|
||||||
|
expect(existsSync(join(root, "manifest.json"))).toBe(false);
|
||||||
|
expect(existsSync(join(root, "manifest.json.migrated"))).toBe(true);
|
||||||
|
expect(existsSync(metaPath(root, "aaa"))).toBe(true);
|
||||||
|
expect(existsSync(metaPath(root, "bbb"))).toBe(true);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("appendStream", () => {
|
||||||
|
test("drops unknown sessions and appends known ones", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const index = { known: { name: "known", created: 1, players: [] } };
|
||||||
|
mkdirSync(sessionDir(root, "known"), { recursive: true });
|
||||||
|
|
||||||
|
appendStream(root, "ghost", index, '{"x":1}');
|
||||||
|
expect(existsSync(streamPath(root, "ghost"))).toBe(false);
|
||||||
|
|
||||||
|
appendStream(root, "known", index, '{"x":1}');
|
||||||
|
expect(readFileSync(streamPath(root, "known"), "utf-8")).toBe('{"x":1}\n');
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses to build a path from a malformed id", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
mkdirSync(join(root, "sessions"), { recursive: true });
|
||||||
|
const index: Record<string, SessionMeta> = {
|
||||||
|
"..": { name: "evil", created: 1, players: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
appendStream(root, "..", index, '{"x":1}');
|
||||||
|
|
||||||
|
expect(existsSync(join(root, "stream.jsonl"))).toBe(false);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractSessionId", () => {
|
||||||
|
test("returns a valid id from a session topic", () => {
|
||||||
|
expect(extractSessionId("ttrpg/my-game/stream")).toBe("my-game");
|
||||||
|
expect(extractSessionId("ttrpg/A_1/meta")).toBe("A_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects traversal, empty and malformed ids", () => {
|
||||||
|
expect(extractSessionId("ttrpg/../meta")).toBeNull();
|
||||||
|
expect(extractSessionId("ttrpg/./meta")).toBeNull();
|
||||||
|
expect(extractSessionId("ttrpg/..\\..\\evil/meta")).toBeNull();
|
||||||
|
expect(extractSessionId("ttrpg/a b/meta")).toBeNull();
|
||||||
|
expect(extractSessionId("ttrpg//meta")).toBeNull();
|
||||||
|
expect(extractSessionId("ttrpg/meta")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleMetaChange", () => {
|
||||||
|
// Minimal broker stub: handleMetaChange only needs publish() for the
|
||||||
|
// manifest broadcast.
|
||||||
|
function makeBroker() {
|
||||||
|
const published: { topic: string; payload: string }[] = [];
|
||||||
|
const broker = {
|
||||||
|
publish: (
|
||||||
|
packet: { topic: string; payload: Buffer },
|
||||||
|
cb?: () => void,
|
||||||
|
) => {
|
||||||
|
published.push({
|
||||||
|
topic: packet.topic,
|
||||||
|
payload: packet.payload.toString(),
|
||||||
|
});
|
||||||
|
cb?.();
|
||||||
|
},
|
||||||
|
} as unknown as import("aedes").Aedes;
|
||||||
|
return { broker, published };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("writes validated meta, updates the index and publishes the manifest", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const index: Record<string, SessionMeta> = {};
|
||||||
|
const { broker, published } = makeBroker();
|
||||||
|
|
||||||
|
handleMetaChange(
|
||||||
|
root,
|
||||||
|
"game",
|
||||||
|
broker,
|
||||||
|
index,
|
||||||
|
JSON.stringify({ name: "Game", created: 10, players: ["alice"] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(index["game"]).toEqual({
|
||||||
|
name: "Game",
|
||||||
|
created: 10,
|
||||||
|
players: ["alice"],
|
||||||
|
});
|
||||||
|
expect(readFileSync(metaPath(root, "game"), "utf-8")).toContain("Game");
|
||||||
|
expect(published.some((p) => p.topic === "ttrpg/$SESSIONS")).toBe(true);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deletes a session on an empty payload", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
mkdirSync(sessionDir(root, "doomed"), { recursive: true });
|
||||||
|
writeFileAtomic(
|
||||||
|
metaPath(root, "doomed"),
|
||||||
|
JSON.stringify({ name: "Doomed", created: 1, players: [] }),
|
||||||
|
);
|
||||||
|
const index: Record<string, SessionMeta> = {
|
||||||
|
doomed: { name: "Doomed", created: 1, players: [] },
|
||||||
|
};
|
||||||
|
const { broker } = makeBroker();
|
||||||
|
|
||||||
|
handleMetaChange(root, "doomed", broker, index, "");
|
||||||
|
|
||||||
|
expect(index["doomed"]).toBeUndefined();
|
||||||
|
expect(existsSync(sessionDir(root, "doomed"))).toBe(false);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses malformed ids and never touches the filesystem", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
mkdirSync(join(root, "sessions"), { recursive: true });
|
||||||
|
const index: Record<string, SessionMeta> = {};
|
||||||
|
const { broker } = makeBroker();
|
||||||
|
|
||||||
|
handleMetaChange(root, "..", broker, index, JSON.stringify({ name: "evil" }));
|
||||||
|
handleMetaChange(root, ".", broker, index, "");
|
||||||
|
handleMetaChange(root, "..\\..\\evil", broker, index, "");
|
||||||
|
|
||||||
|
expect(existsSync(join(root, "meta.json"))).toBe(false);
|
||||||
|
expect(existsSync(join(root, "sessions", "meta.json"))).toBe(false);
|
||||||
|
expect(Object.keys(index)).toHaveLength(0);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores malformed meta without deleting the session", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
mkdirSync(sessionDir(root, "keep"), { recursive: true });
|
||||||
|
writeFileAtomic(
|
||||||
|
metaPath(root, "keep"),
|
||||||
|
JSON.stringify({ name: "Keep", created: 1, players: [] }),
|
||||||
|
);
|
||||||
|
const index: Record<string, SessionMeta> = {
|
||||||
|
keep: { name: "Keep", created: 1, players: [] },
|
||||||
|
};
|
||||||
|
const { broker } = makeBroker();
|
||||||
|
|
||||||
|
handleMetaChange(
|
||||||
|
root,
|
||||||
|
"keep",
|
||||||
|
broker,
|
||||||
|
index,
|
||||||
|
JSON.stringify({ players: "not-an-array" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(index["keep"]).toEqual({ name: "Keep", created: 1, players: [] });
|
||||||
|
expect(existsSync(metaPath(root, "keep"))).toBe(true);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
});
|
||||||
+307
-83
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 日志流持久化 — MQTT 代理 + JSONL 追加写入 + 会话清单
|
* 日志流持久化 — MQTT 代理 + JSONL 追加写入 + 按会话元数据
|
||||||
*
|
*
|
||||||
* 在现有 HTTP 服务器上挂载 aedes MQTT 代理(WebSocket 传输),
|
* 在现有 HTTP 服务器上挂载 aedes MQTT 代理(WebSocket 传输),
|
||||||
* 使用 ws v8 内置的 createWebSocketStream。
|
* 使用 ws v8 内置的 createWebSocketStream。
|
||||||
@@ -9,31 +9,52 @@
|
|||||||
*
|
*
|
||||||
* 持久化使用 aedes 内部 subscribe/publish API,无需服务器进程
|
* 持久化使用 aedes 内部 subscribe/publish API,无需服务器进程
|
||||||
* 以 MQTT 客户端身份连接自身。
|
* 以 MQTT 客户端身份连接自身。
|
||||||
|
*
|
||||||
|
* 存储模型:目录即事实来源。会话存在当且仅当
|
||||||
|
* `sessions/<id>/meta.json` 存在且可解析;不再维护中心化清单,
|
||||||
|
* 会话列表在内存中派生并通过保留主题 `ttrpg/$SESSIONS` 发布。
|
||||||
|
* 元数据写入使用「临时文件 + fsync + 原子重命名」,避免崩溃时
|
||||||
|
* 截断损坏;损坏的元数据会被隔离而非静默丢弃。
|
||||||
|
*
|
||||||
|
* 会话 ID 会直接决定文件系统路径,因此在 MQTT 边界严格校验
|
||||||
|
* (见 shared/session-id)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { join, resolve } from "path";
|
import { join, resolve } from "path";
|
||||||
import {
|
import {
|
||||||
appendFileSync,
|
appendFileSync,
|
||||||
|
closeSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
|
fsyncSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
|
openSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
rmdirSync,
|
readdirSync,
|
||||||
unlinkSync,
|
readSync,
|
||||||
writeFileSync,
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
truncateSync,
|
||||||
|
writeSync,
|
||||||
} from "fs";
|
} from "fs";
|
||||||
import type { Server as HttpServer } from "http";
|
import type { Server as HttpServer } from "http";
|
||||||
import type { AedesPublishPacket } from "aedes";
|
|
||||||
import { WebSocketServer, createWebSocketStream } from "ws";
|
import { WebSocketServer, createWebSocketStream } from "ws";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { isValidSessionId } from "../shared/session-id.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 类型定义
|
// 类型定义
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface SessionMeta {
|
const SessionMetaSchema = z.object({
|
||||||
name: string;
|
name: z.string().min(1),
|
||||||
created: number;
|
created: z.number().default(() => Date.now()),
|
||||||
players: string[];
|
players: z.array(z.string()).default([]),
|
||||||
}
|
});
|
||||||
|
|
||||||
|
type SessionMeta = z.infer<typeof SessionMetaSchema>;
|
||||||
|
|
||||||
|
export type { SessionMeta };
|
||||||
|
|
||||||
interface SessionManifest {
|
interface SessionManifest {
|
||||||
sessions: Record<string, SessionMeta>;
|
sessions: Record<string, SessionMeta>;
|
||||||
@@ -49,42 +70,196 @@ export interface JournalServer {
|
|||||||
// 路径工具函数
|
// 路径工具函数
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function dataDir(contentDir: string): string {
|
export function dataDir(contentDir: string): string {
|
||||||
const d = resolve(contentDir, ".ttrpg");
|
const d = resolve(contentDir, ".ttrpg");
|
||||||
mkdirSync(d, { recursive: true });
|
mkdirSync(d, { recursive: true });
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
function manifestPath(dataRoot: string): string {
|
export function sessionDir(dataRoot: string, id: string): string {
|
||||||
return join(dataRoot, "manifest.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionDir(dataRoot: string, id: string): string {
|
|
||||||
return join(dataRoot, "sessions", id);
|
return join(dataRoot, "sessions", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamPath(dataRoot: string, id: string): string {
|
export function metaPath(dataRoot: string, id: string): string {
|
||||||
|
return join(sessionDir(dataRoot, id), "meta.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function streamPath(dataRoot: string, id: string): string {
|
||||||
return join(sessionDir(dataRoot, id), "stream.jsonl");
|
return join(sessionDir(dataRoot, id), "stream.jsonl");
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadManifest(dataRoot: string): SessionManifest {
|
// ---------------------------------------------------------------------------
|
||||||
const p = manifestPath(dataRoot);
|
// 原子写入
|
||||||
if (!existsSync(p)) return { sessions: {} };
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let _tmpCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子写入:先写临时文件并 fsync,再重命名覆盖目标。
|
||||||
|
* rename 在同一文件系统上是原子操作(Windows 上由 libuv 以
|
||||||
|
* MOVEFILE_REPLACE_EXISTING 实现),因此崩溃时目标文件要么是旧内容,
|
||||||
|
* 要么是新内容,绝不会是截断的中间态。
|
||||||
|
*/
|
||||||
|
export function writeFileAtomic(path: string, data: string): void {
|
||||||
|
const tmp = `${path}.tmp-${process.pid}-${Date.now()}-${_tmpCounter++}`;
|
||||||
|
const fd = openSync(tmp, "w");
|
||||||
try {
|
try {
|
||||||
return JSON.parse(readFileSync(p, "utf-8"));
|
writeSync(fd, data);
|
||||||
} catch {
|
fsyncSync(fd);
|
||||||
return { sessions: {} };
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
renameSync(tmp, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 流文件修复
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修复崩溃时被截断的尾行:若文件不以换行结尾,则截断到最后一个换行。
|
||||||
|
* 否则后续追加会与残行粘连,产生一条损坏记录。
|
||||||
|
*/
|
||||||
|
export function repairStreamTail(path: string): void {
|
||||||
|
if (!existsSync(path)) return;
|
||||||
|
try {
|
||||||
|
const size = statSync(path).size;
|
||||||
|
if (size === 0) return;
|
||||||
|
|
||||||
|
const fd = openSync(path, "r");
|
||||||
|
const last = Buffer.alloc(1);
|
||||||
|
try {
|
||||||
|
readSync(fd, last, 0, 1, size - 1);
|
||||||
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
if (last[0] === 0x0a) return;
|
||||||
|
|
||||||
|
const buf = readFileSync(path);
|
||||||
|
const lastNl = buf.lastIndexOf(0x0a);
|
||||||
|
truncateSync(path, lastNl === -1 ? 0 : lastNl + 1);
|
||||||
|
console.warn(`[日志] 已修复截断的流尾行:${path}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[日志] 修复流文件失败:${path}`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveManifest(dataRoot: string, m: SessionManifest): void {
|
// ---------------------------------------------------------------------------
|
||||||
writeFileSync(manifestPath(dataRoot), JSON.stringify(m, null, 2), "utf-8");
|
// 会话扫描 / 迁移
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function safeMtime(path: string): number {
|
||||||
|
try {
|
||||||
|
return statSync(path).mtimeMs;
|
||||||
|
} catch {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendStream(dataRoot: string, id: string, line: string): void {
|
/**
|
||||||
const sd = sessionDir(dataRoot, id);
|
* 扫描 sessions/ 目录,构建内存索引。
|
||||||
mkdirSync(sd, { recursive: true });
|
*
|
||||||
appendFileSync(streamPath(dataRoot, id), line + "\n", "utf-8");
|
* - 有合法 meta.json 的目录 → 直接纳入索引;
|
||||||
|
* - meta.json 损坏 → 隔离为 meta.json.corrupt-<ts>,再尝试接管;
|
||||||
|
* - 无 meta.json 但有 stream.jsonl → 接管(用目录名与流文件 mtime 合成元数据);
|
||||||
|
* - 其余目录 → 忽略。
|
||||||
|
*/
|
||||||
|
export function scanSessions(dataRoot: string): Record<string, SessionMeta> {
|
||||||
|
const sessionsRoot = join(dataRoot, "sessions");
|
||||||
|
mkdirSync(sessionsRoot, { recursive: true });
|
||||||
|
|
||||||
|
const index: Record<string, SessionMeta> = {};
|
||||||
|
|
||||||
|
for (const entry of readdirSync(sessionsRoot, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const id = entry.name;
|
||||||
|
if (!isValidSessionId(id)) {
|
||||||
|
console.warn(`[日志] 跳过非法会话目录:${id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const mp = metaPath(dataRoot, id);
|
||||||
|
const sp = streamPath(dataRoot, id);
|
||||||
|
|
||||||
|
if (existsSync(mp)) {
|
||||||
|
try {
|
||||||
|
const meta = SessionMetaSchema.parse(
|
||||||
|
JSON.parse(readFileSync(mp, "utf-8")),
|
||||||
|
);
|
||||||
|
index[id] = meta;
|
||||||
|
repairStreamTail(sp);
|
||||||
|
continue;
|
||||||
|
} catch (e) {
|
||||||
|
const quarantine = `${mp}.corrupt-${Date.now()}`;
|
||||||
|
try {
|
||||||
|
renameSync(mp, quarantine);
|
||||||
|
console.error(
|
||||||
|
`[日志] 会话 ${id} 元数据损坏,已隔离到 ${quarantine}:`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
} catch (renameErr) {
|
||||||
|
console.error(`[日志] 会话 ${id} 元数据损坏且隔离失败:`, renameErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(sp)) {
|
||||||
|
const meta: SessionMeta = {
|
||||||
|
name: id,
|
||||||
|
created: safeMtime(sp),
|
||||||
|
players: [],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
writeFileAtomic(mp, JSON.stringify(meta, null, 2));
|
||||||
|
index[id] = meta;
|
||||||
|
repairStreamTail(sp);
|
||||||
|
console.log(`[日志] 已接管孤立会话:${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[日志] 接管孤立会话失败:${id}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移旧的中心化 manifest.json:把每个会话条目拆分为
|
||||||
|
* sessions/<id>/meta.json,然后把旧文件重命名为 manifest.json.migrated。
|
||||||
|
* 已存在 meta.json 的会话会被跳过(支持中断后重跑)。
|
||||||
|
*/
|
||||||
|
export function migrateLegacyManifest(dataRoot: string): void {
|
||||||
|
const legacy = join(dataRoot, "manifest.json");
|
||||||
|
if (!existsSync(legacy)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(legacy, "utf-8")) as SessionManifest;
|
||||||
|
const sessions = parsed?.sessions ?? {};
|
||||||
|
let migrated = 0;
|
||||||
|
|
||||||
|
for (const [id, raw] of Object.entries(sessions)) {
|
||||||
|
if (!isValidSessionId(id)) {
|
||||||
|
console.warn(`[日志] 跳过非法会话 ID:${id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const mp = metaPath(dataRoot, id);
|
||||||
|
if (existsSync(mp)) continue;
|
||||||
|
try {
|
||||||
|
const meta = SessionMetaSchema.parse(raw);
|
||||||
|
mkdirSync(sessionDir(dataRoot, id), { recursive: true });
|
||||||
|
writeFileAtomic(mp, JSON.stringify(meta, null, 2));
|
||||||
|
migrated++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[日志] 跳过无法迁移的会话 ${id}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renameSync(legacy, `${legacy}.migrated`);
|
||||||
|
console.log(
|
||||||
|
`[日志] 已迁移 ${migrated} 个会话到按会话元数据;旧清单重命名为 manifest.json.migrated`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[日志] 旧清单迁移失败,保留原文件:", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -93,6 +268,24 @@ function appendStream(dataRoot: string, id: string, line: string): void {
|
|||||||
|
|
||||||
const $SESSIONS = "ttrpg/$SESSIONS";
|
const $SESSIONS = "ttrpg/$SESSIONS";
|
||||||
|
|
||||||
|
function publishManifest(
|
||||||
|
broker: import("aedes").Aedes,
|
||||||
|
index: Record<string, SessionMeta>,
|
||||||
|
): void {
|
||||||
|
const manifest: SessionManifest = { sessions: index };
|
||||||
|
broker.publish(
|
||||||
|
{
|
||||||
|
topic: $SESSIONS,
|
||||||
|
payload: Buffer.from(JSON.stringify(manifest)),
|
||||||
|
qos: 1,
|
||||||
|
retain: true,
|
||||||
|
cmd: "publish" as const,
|
||||||
|
dup: false,
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createJournalServer(
|
export async function createJournalServer(
|
||||||
contentDir: string,
|
contentDir: string,
|
||||||
httpServer: HttpServer,
|
httpServer: HttpServer,
|
||||||
@@ -100,6 +293,11 @@ export async function createJournalServer(
|
|||||||
const root = dataDir(contentDir);
|
const root = dataDir(contentDir);
|
||||||
console.log(`[日志] 数据目录:${root}`);
|
console.log(`[日志] 数据目录:${root}`);
|
||||||
|
|
||||||
|
// 迁移旧清单并扫描现有会话,构建内存索引(目录即事实来源)
|
||||||
|
migrateLegacyManifest(root);
|
||||||
|
const index = scanSessions(root);
|
||||||
|
console.log(`[日志] 已加载 ${Object.keys(index).length} 个会话`);
|
||||||
|
|
||||||
// ---- MQTT 代理 ----
|
// ---- MQTT 代理 ----
|
||||||
const { Aedes: AedesFactory } = await import("aedes");
|
const { Aedes: AedesFactory } = await import("aedes");
|
||||||
// aedes 需要先 listen() 初始化持久化,再处理连接
|
// aedes 需要先 listen() 初始化持久化,再处理连接
|
||||||
@@ -128,7 +326,7 @@ export async function createJournalServer(
|
|||||||
const sessionId = extractSessionId(packet.topic);
|
const sessionId = extractSessionId(packet.topic);
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
try {
|
try {
|
||||||
appendStream(root, sessionId, packet.payload.toString());
|
appendStream(root, sessionId, index, packet.payload.toString());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[日志] 会话 ${sessionId} 流记录写入失败:`, e);
|
console.error(`[日志] 会话 ${sessionId} 流记录写入失败:`, e);
|
||||||
}
|
}
|
||||||
@@ -143,7 +341,13 @@ export async function createJournalServer(
|
|||||||
(packet, cb) => {
|
(packet, cb) => {
|
||||||
const sessionId = extractSessionId(packet.topic);
|
const sessionId = extractSessionId(packet.topic);
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
handleMetaChange(root, sessionId, broker, packet.payload.toString());
|
handleMetaChange(
|
||||||
|
root,
|
||||||
|
sessionId,
|
||||||
|
broker,
|
||||||
|
index,
|
||||||
|
packet.payload.toString(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
cb();
|
cb();
|
||||||
},
|
},
|
||||||
@@ -151,18 +355,7 @@ export async function createJournalServer(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 启动时发布初始清单,让客户端能看到已有会话
|
// 启动时发布初始清单,让客户端能看到已有会话
|
||||||
const initialManifest = loadManifest(root);
|
publishManifest(broker, index);
|
||||||
broker.publish(
|
|
||||||
{
|
|
||||||
topic: $SESSIONS,
|
|
||||||
payload: Buffer.from(JSON.stringify(initialManifest)),
|
|
||||||
qos: 1,
|
|
||||||
retain: true,
|
|
||||||
cmd: "publish" as const,
|
|
||||||
dup: false,
|
|
||||||
},
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("[日志] 持久化监听已启动");
|
console.log("[日志] 持久化监听已启动");
|
||||||
console.log("[日志] MQTT 代理已就绪:ws://<host>:<port>");
|
console.log("[日志] MQTT 代理已就绪:ws://<host>:<port>");
|
||||||
@@ -182,60 +375,91 @@ export async function createJournalServer(
|
|||||||
// 工具函数
|
// 工具函数
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function extractSessionId(topic: string): string | null {
|
export function extractSessionId(topic: string): string | null {
|
||||||
const parts = topic.split("/");
|
const parts = topic.split("/");
|
||||||
return parts.length >= 3 ? parts[1] : null;
|
if (parts.length < 3) return null;
|
||||||
|
const id = parts[1];
|
||||||
|
if (!isValidSessionId(id)) {
|
||||||
|
console.warn(`[日志] 拒绝非法会话 ID:${JSON.stringify(id)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 会话生命周期管理
|
// 会话生命周期管理
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function handleMetaChange(
|
/**
|
||||||
|
* 追加一条流记录。会话必须已在索引中(即存在合法 meta.json),
|
||||||
|
* 否则丢弃——这样已删除的会话不会被迟到的消息「复活」。
|
||||||
|
* 目录由元数据处理函数创建,此处不再 mkdir。
|
||||||
|
*/
|
||||||
|
export function appendStream(
|
||||||
|
root: string,
|
||||||
|
id: string,
|
||||||
|
index: Record<string, SessionMeta>,
|
||||||
|
line: string,
|
||||||
|
): void {
|
||||||
|
if (!isValidSessionId(id) || !index[id]) {
|
||||||
|
console.warn(`[日志] 丢弃未知会话 ${id} 的流记录`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appendFileSync(streamPath(root, id), line + "\n", "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleMetaChange(
|
||||||
root: string,
|
root: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
broker: import("aedes").Aedes,
|
broker: import("aedes").Aedes,
|
||||||
|
index: Record<string, SessionMeta>,
|
||||||
rawPayload: string,
|
rawPayload: string,
|
||||||
): void {
|
): void {
|
||||||
const manifest = loadManifest(root);
|
// The id reaches us straight from an MQTT topic level; never build a path
|
||||||
try {
|
// from it without validating, or `..` can escape the sessions directory.
|
||||||
const meta = JSON.parse(rawPayload) as SessionMeta | null;
|
if (!isValidSessionId(sessionId)) {
|
||||||
|
console.warn(
|
||||||
if (!meta || !meta.name) {
|
`[日志] 拒绝非法会话 ID 的元数据操作:${JSON.stringify(sessionId)}`,
|
||||||
if (manifest.sessions[sessionId]) {
|
|
||||||
delete manifest.sessions[sessionId];
|
|
||||||
const sp = streamPath(root, sessionId);
|
|
||||||
if (existsSync(sp)) unlinkSync(sp);
|
|
||||||
const sd = sessionDir(root, sessionId);
|
|
||||||
try {
|
|
||||||
rmdirSync(sd);
|
|
||||||
} catch {
|
|
||||||
/* */
|
|
||||||
}
|
|
||||||
console.log(`[日志] 会话已删除:${sessionId}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
manifest.sessions[sessionId] = {
|
|
||||||
name: meta.name,
|
|
||||||
created: meta.created || Date.now(),
|
|
||||||
players: meta.players || [],
|
|
||||||
};
|
|
||||||
console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveManifest(root, manifest);
|
|
||||||
broker.publish(
|
|
||||||
{
|
|
||||||
topic: $SESSIONS,
|
|
||||||
payload: Buffer.from(JSON.stringify(manifest)),
|
|
||||||
qos: 1,
|
|
||||||
retain: true,
|
|
||||||
cmd: "publish" as const,
|
|
||||||
dup: false,
|
|
||||||
},
|
|
||||||
() => {},
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = rawPayload.trim();
|
||||||
|
|
||||||
|
// 空载荷(或 JSON null)= 删除会话:递归删除目录,移除索引项。
|
||||||
|
// 必须在 JSON.parse 之前判断:JSON.parse("") 会抛错,导致删除永远无法生效。
|
||||||
|
if (payload === "" || payload === "null") {
|
||||||
|
delete index[sessionId];
|
||||||
|
rmSync(sessionDir(root, sessionId), { recursive: true, force: true });
|
||||||
|
console.log(`[日志] 会话已删除:${sessionId}`);
|
||||||
|
publishManifest(broker, index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(payload);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[日志] 会话 ${sessionId} 元数据解析失败:`, e);
|
console.error(`[日志] 会话 ${sessionId} 元数据解析失败:`, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用 schema 校验,避免写入无法回读的数据(例如非数组的 players),
|
||||||
|
// 那会让下次启动扫描时把元数据隔离掉。
|
||||||
|
const parsed = SessionMetaSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error(`[日志] 会话 ${sessionId} 元数据无效,已忽略:`, parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = parsed.data;
|
||||||
|
try {
|
||||||
|
mkdirSync(sessionDir(root, sessionId), { recursive: true });
|
||||||
|
writeFileAtomic(metaPath(root, sessionId), JSON.stringify(meta, null, 2));
|
||||||
|
index[sessionId] = meta;
|
||||||
|
console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`);
|
||||||
|
publishManifest(broker, index);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[日志] 会话 ${sessionId} 元数据写入失败:`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
* Code-block role checks.
|
* Code-block role checks.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parse } from "csv-parse/browser/esm/sync";
|
import { parseDeclare, type DeclareResult } from "../../completions/declare-parser.js";
|
||||||
import { parseDeclareCsv } from "../../completions/declare-parser.js";
|
|
||||||
import { diagnosticAt } from "../diagnostics.js";
|
import { diagnosticAt } from "../diagnostics.js";
|
||||||
import { ELEMENT_NAMES, KNOWN_ROLES } from "../known.js";
|
import { ELEMENT_NAMES, KNOWN_ROLES } from "../known.js";
|
||||||
import type { RawFence } from "../scan.js";
|
import type { RawFence } from "../scan.js";
|
||||||
@@ -60,8 +59,25 @@ function checkSparkTable(fence: RawFence, ctx: CheckContext): void {
|
|||||||
|
|
||||||
function checkDeclare(fence: RawFence, ctx: CheckContext): void {
|
function checkDeclare(fence: RawFence, ctx: CheckContext): void {
|
||||||
const { source, file, sink } = ctx;
|
const { source, file, sink } = ctx;
|
||||||
|
|
||||||
|
if (/^csv$/i.test(fence.lang)) {
|
||||||
|
sink.report(
|
||||||
|
diagnosticAt(
|
||||||
|
file,
|
||||||
|
source,
|
||||||
|
fence.offset,
|
||||||
|
"TTTK-R003",
|
||||||
|
"error",
|
||||||
|
"role=declare blocks are YAML, not CSV",
|
||||||
|
"use ```yaml role=declare with `vars:` and `tags:` maps",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: DeclareResult;
|
||||||
try {
|
try {
|
||||||
parseDeclareCsv(fence.body, file);
|
result = parseDeclare(fence.body);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sink.report(
|
sink.report(
|
||||||
diagnosticAt(
|
diagnosticAt(
|
||||||
@@ -71,51 +87,22 @@ function checkDeclare(fence: RawFence, ctx: CheckContext): void {
|
|||||||
"TTTK-R003",
|
"TTTK-R003",
|
||||||
"error",
|
"error",
|
||||||
error instanceof Error ? error.message : String(error),
|
error instanceof Error ? error.message : String(error),
|
||||||
"role=declare needs `key` and `expr` columns; `tag` and `threshold` are optional",
|
"role=declare is YAML: `vars:` maps `$name` to an expression; `tags:` maps a tag name to threshold maps",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
checkDeclareRows(fence, ctx);
|
if (result.variables.length === 0 && result.tagModifiers.length === 0) {
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* R004 — `parseDeclareCsv` silently skips rows with an empty `key` or `expr`.
|
|
||||||
* Re-parse with line info so the skipped row can be localized.
|
|
||||||
*/
|
|
||||||
function checkDeclareRows(fence: RawFence, ctx: CheckContext): void {
|
|
||||||
const { source, file, sink } = ctx;
|
|
||||||
let records: Array<{ record: Record<string, string>; info: { lines: number } }>;
|
|
||||||
try {
|
|
||||||
records = parse(fence.body, {
|
|
||||||
columns: true,
|
|
||||||
trim: true,
|
|
||||||
skipEmptyLines: true,
|
|
||||||
info: true,
|
|
||||||
}) as unknown as typeof records;
|
|
||||||
} catch {
|
|
||||||
return; // R003 already reported the parse failure
|
|
||||||
}
|
|
||||||
if (records.length === 0) return;
|
|
||||||
|
|
||||||
const headers = Object.keys(records[0].record).map((h) => h.toLowerCase());
|
|
||||||
if (!headers.includes("key") || !headers.includes("expr")) return;
|
|
||||||
|
|
||||||
const baseLine = source.position(fence.bodyOffset).line;
|
|
||||||
for (const { record, info } of records) {
|
|
||||||
const key = record.key ?? "";
|
|
||||||
const expr = record.expr ?? "";
|
|
||||||
if (key && expr) continue;
|
|
||||||
sink.report(
|
sink.report(
|
||||||
diagnosticAt(
|
diagnosticAt(
|
||||||
file,
|
file,
|
||||||
source,
|
source,
|
||||||
source.lineStart(baseLine + info.lines - 1),
|
fence.offset,
|
||||||
"TTTK-R004",
|
"TTTK-R004",
|
||||||
"warning",
|
"warning",
|
||||||
`declare row skipped: ${!key ? "empty key" : "empty expr"}`,
|
"role=declare block declares nothing",
|
||||||
"every declare row needs both a key and an expression",
|
"add a `vars:` or `tags:` mapping; the block has no effect",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-13
@@ -67,6 +67,30 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
|||||||
':md-deck[./cards.csv]{size="63x88" grid="5x5" layers="title:1,1-5,1f8 body:1,5-5,8f3"}',
|
':md-deck[./cards.csv]{size="63x88" grid="5x5" layers="title:1,1-5,1f8 body:1,5-5,8f3"}',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "md-print",
|
||||||
|
body: "markdown path, optional #section",
|
||||||
|
attrs: [
|
||||||
|
"columns",
|
||||||
|
"margin",
|
||||||
|
"gap",
|
||||||
|
"font",
|
||||||
|
"title",
|
||||||
|
"pageNumbers",
|
||||||
|
"timestamp",
|
||||||
|
"cutMarks",
|
||||||
|
],
|
||||||
|
summary:
|
||||||
|
"Paginates a markdown document into A4 landscape sheets for printing.",
|
||||||
|
details:
|
||||||
|
"Each sheet holds two A5 pages side by side, and each A5 page flows the markdown into `columns` columns. `margin`, `gap`, and `font` are in millimetres (the body font defaults to 3mm). `pageNumbers` adds an `n / total` footer in the outer corner, `timestamp` appends the export time to that footer, and `cutMarks` draws the centre cut line. The preview modal exports through the browser's print dialog (choose A4 landscape, no margins, 100% scale).",
|
||||||
|
refKind: "md",
|
||||||
|
examples: [
|
||||||
|
':md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}',
|
||||||
|
':md-print[./rules.md]{timestamp=true}',
|
||||||
|
"```yaml role=tag\ntag: md-print\nbody: ./rules.md\ncolumns: 2\nmargin: 10\ngap: 5\nfont: 3\ntitle: 规则书\ntimestamp: true\n```",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "md-link",
|
name: "md-link",
|
||||||
body: "path, optional #section",
|
body: "path, optional #section",
|
||||||
@@ -131,16 +155,6 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
|||||||
':md-pins[./images/dungeon.png]{labelStart="1"}',
|
':md-pins[./images/dungeon.png]{labelStart="1"}',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "md-commander",
|
|
||||||
body: "command template CSV",
|
|
||||||
attrs: ["placeholder", "class", "height"],
|
|
||||||
summary: "Interactive command history / tracker panel.",
|
|
||||||
details:
|
|
||||||
"The body is a CSV of command templates (`command,parameter,label,description,insertedText`).",
|
|
||||||
refKind: "csv",
|
|
||||||
examples: [':md-commander[./commands.csv]{placeholder="输入命令…" height="400px"}'],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "md-yarn-spinner",
|
name: "md-yarn-spinner",
|
||||||
body: "Yarn Spinner file",
|
body: "Yarn Spinner file",
|
||||||
@@ -232,12 +246,12 @@ export const ROLE_SPECS: readonly RoleSpec[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "declare",
|
role: "declare",
|
||||||
info: "csv role=declare",
|
info: "yaml role=declare",
|
||||||
summary: "Reactive variable / tag-modifier definitions.",
|
summary: "Reactive variable / tag-modifier definitions.",
|
||||||
details:
|
details:
|
||||||
"Columns (any order): `tag`, `threshold`, `key`, `expr`. A row with an empty `tag` declares a variable; a row with a `#tag` applies a modifier when that tag is active.",
|
"YAML with two optional maps. `vars:` maps a `$name` to its expression. `tags:` maps a tag name to a map of thresholds to `$target` modifiers: at that tag count the target gains the value, so sibling thresholds stack. Tag names may be written with or without the leading `#`. YAML resolves bare numbers to numbers and everything else to strings; both become expression text.",
|
||||||
examples: [
|
examples: [
|
||||||
"```csv role=declare\ntag,threshold,key,expr\n,,$hp,$con*5+$mod_hp\n#warrior,2,$mod_hp,20\n```",
|
"```yaml role=declare\nvars:\n $hp: $con*5 + $mod_hp\n $ac: 10 + $dex\ntags:\n warrior:\n 1:\n $mod_hp: 20\n 2:\n $mod_str: 1\n```",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -282,6 +296,7 @@ export interface DiceNotation {
|
|||||||
export const DICE_NOTATION: readonly DiceNotation[] = [
|
export const DICE_NOTATION: readonly DiceNotation[] = [
|
||||||
{ notation: "2d6", meaning: "two six-sided dice" },
|
{ notation: "2d6", meaning: "two six-sided dice" },
|
||||||
{ notation: "1d20+5", meaning: "d20 plus modifier" },
|
{ notation: "1d20+5", meaning: "d20 plus modifier" },
|
||||||
|
{ notation: "d20", meaning: "one twenty-sided die (count optional)" },
|
||||||
{ notation: "3d6kh1", meaning: "keep highest 1" },
|
{ notation: "3d6kh1", meaning: "keep highest 1" },
|
||||||
{ notation: "3d6dl2-1", meaning: "drop lowest 2, minus 1" },
|
{ notation: "3d6dl2-1", meaning: "drop lowest 2, minus 1" },
|
||||||
{ notation: "{d4, d8}", meaning: "choose one of the listed dice" },
|
{ notation: "{d4, d8}", meaning: "choose one of the listed dice" },
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ export function renderReference(): string {
|
|||||||
);
|
);
|
||||||
lines.push("handled.");
|
lines.push("handled.");
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
lines.push(
|
||||||
|
"Fences may use backticks or tildes (`~~~`); the closing fence must reuse",
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
"the opening marker. Fence attribute keys may contain hyphens (e.g.",
|
||||||
|
);
|
||||||
|
lines.push("`data-foo=bar`).");
|
||||||
|
lines.push("");
|
||||||
for (const role of ROLE_SPECS) {
|
for (const role of ROLE_SPECS) {
|
||||||
lines.push(`### \`role=${role.role}\``);
|
lines.push(`### \`role=${role.role}\``);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|||||||
@@ -65,14 +65,26 @@ describe("runCheck", () => {
|
|||||||
expect(report.diagnostics[0].line).toBe(1);
|
expect(report.diagnostics[0].line).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("R003 flags a declare block missing key/expr columns", () => {
|
test("R003 flags a declare block with an invalid shape", () => {
|
||||||
const dir = fixture({
|
const dir = fixture({
|
||||||
"doc.md": "```csv role=declare\nfoo,bar\n1,2\n```\n",
|
"doc.md": "```yaml role=declare\nvars:\n hp: 1\n```\n",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(codes(runCheck(dir))).toContain("TTTK-R003");
|
expect(codes(runCheck(dir))).toContain("TTTK-R003");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("R003 flags a CSV role=declare block with a migration hint", () => {
|
||||||
|
const dir = fixture({
|
||||||
|
"doc.md": "```csv role=declare\ntag,key,expr\n,,$hp,1\n```\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = runCheck(dir);
|
||||||
|
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R003");
|
||||||
|
|
||||||
|
expect(diagnostic).toBeDefined();
|
||||||
|
expect(diagnostic!.message).toContain("YAML, not CSV");
|
||||||
|
});
|
||||||
|
|
||||||
test("R005 flags invalid YAML in a role=tag block", () => {
|
test("R005 flags invalid YAML in a role=tag block", () => {
|
||||||
const dir = fixture({
|
const dir = fixture({
|
||||||
"doc.md": "```yaml role=tag\ntag: [unclosed\n```\n",
|
"doc.md": "```yaml role=tag\ntag: [unclosed\n```\n",
|
||||||
@@ -168,16 +180,16 @@ describe("runCheck", () => {
|
|||||||
expect(codes(runCheck(dir))).not.toContain("TTTK-D002");
|
expect(codes(runCheck(dir))).not.toContain("TTTK-D002");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("R004 flags a declare row with an empty key or expr", () => {
|
test("R004 flags a declare block that declares nothing", () => {
|
||||||
const dir = fixture({
|
const dir = fixture({
|
||||||
"doc.md": "```csv role=declare\nkey,expr\n$hp,1\n,2\n```\n",
|
"doc.md": "```yaml role=declare\n# nothing here\n```\n",
|
||||||
});
|
});
|
||||||
|
|
||||||
const report = runCheck(dir);
|
const report = runCheck(dir);
|
||||||
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R004");
|
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R004");
|
||||||
|
|
||||||
expect(diagnostic).toBeDefined();
|
expect(diagnostic).toBeDefined();
|
||||||
expect(diagnostic!.line).toBe(4);
|
expect(diagnostic!.line).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("V001 flags a CSV that fails to parse", () => {
|
test("V001 flags a CSV that fails to parse", () => {
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
|
|||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Resolve relative hrefs against the current page path so that
|
// Resolve relative hrefs against the current page path so that
|
||||||
// e.g. "blah.md" on /content/page.md becomes /content/blah
|
// e.g. "blah.md" on /rules/page becomes /rules/blah
|
||||||
const resolved = new URL(href, window.location.origin + window.location.pathname);
|
const resolved = new URL(href, window.location.origin + window.location.pathname);
|
||||||
let pathname = resolved.pathname;
|
let pathname = resolved.pathname;
|
||||||
// Strip .md extension so refreshes hit the SPA fallback, not the raw file
|
// Strip .md extension so refreshes hit the SPA fallback, not the raw file
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ export const CommandLinkManager: Component = () => {
|
|||||||
command,
|
command,
|
||||||
sparkTables: comp.data.sparkTables,
|
sparkTables: comp.data.sparkTables,
|
||||||
variables: stream.variables,
|
variables: stream.variables,
|
||||||
declarations: comp.data.declarations,
|
|
||||||
tagModifiers: comp.data.tagModifiers,
|
|
||||||
}).then((result) => {
|
}).then((result) => {
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setDispatchError(result.error);
|
setDispatchError(result.error);
|
||||||
|
|||||||
@@ -6,8 +6,18 @@ import {
|
|||||||
onCleanup,
|
onCleanup,
|
||||||
onMount,
|
onMount,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
|
import { Dynamic } from "solid-js/web";
|
||||||
|
import {
|
||||||
|
TbOutlineBook2,
|
||||||
|
TbOutlineCheck,
|
||||||
|
TbOutlineChevronDown,
|
||||||
|
TbOutlineClipboardList,
|
||||||
|
TbOutlineX,
|
||||||
|
} from "solid-icons/tb";
|
||||||
import { docEntries, type DocEntry } from "./doc-data";
|
import { docEntries, type DocEntry } from "./doc-data";
|
||||||
import { journalDocEntries, type JournalDocEntry } from "./journal-doc-data";
|
import { journalDocEntries, type JournalDocEntry } from "./journal-doc-data";
|
||||||
|
import { docIcon } from "./doc-icons";
|
||||||
|
import { parseMarkdown } from "../markdown";
|
||||||
|
|
||||||
type DocSet = "directives" | "journal";
|
type DocSet = "directives" | "journal";
|
||||||
type AnyEntry = DocEntry | JournalDocEntry;
|
type AnyEntry = DocEntry | JournalDocEntry;
|
||||||
@@ -76,7 +86,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||||||
class="text-lg font-bold text-gray-900 hover:text-blue-600 flex items-center gap-1"
|
class="text-lg font-bold text-gray-900 hover:text-blue-600 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{docSetLabel()}
|
{docSetLabel()}
|
||||||
<span class="text-gray-400 text-sm">▾</span>
|
<TbOutlineChevronDown class="text-gray-400 text-sm" />
|
||||||
</button>
|
</button>
|
||||||
<Show when={dropdownOpen()}>
|
<Show when={dropdownOpen()}>
|
||||||
<div class="absolute top-full left-0 mt-1 w-52 bg-white border border-gray-200 rounded shadow-lg z-60 py-1">
|
<div class="absolute top-full left-0 mt-1 w-52 bg-white border border-gray-200 rounded shadow-lg z-60 py-1">
|
||||||
@@ -88,10 +98,10 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||||||
: "text-gray-700"
|
: "text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>📖</span>
|
<TbOutlineBook2 />
|
||||||
<span>指令组件文档</span>
|
<span>指令组件文档</span>
|
||||||
<Show when={docSet() === "directives"}>
|
<Show when={docSet() === "directives"}>
|
||||||
<span class="ml-auto text-blue-500">✓</span>
|
<TbOutlineCheck class="ml-auto text-blue-500" />
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -102,10 +112,10 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||||||
: "text-gray-700"
|
: "text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>📋</span>
|
<TbOutlineClipboardList />
|
||||||
<span>Journal 服务器文档</span>
|
<span>Journal 服务器文档</span>
|
||||||
<Show when={docSet() === "journal"}>
|
<Show when={docSet() === "journal"}>
|
||||||
<span class="ml-auto text-blue-500">✓</span>
|
<TbOutlineCheck class="ml-auto text-blue-500" />
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +126,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||||||
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
|
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
|
||||||
title="关闭"
|
title="关闭"
|
||||||
>
|
>
|
||||||
✕
|
<TbOutlineX />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -132,7 +142,10 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||||||
: "text-gray-700 hover:bg-gray-100"
|
: "text-gray-700 hover:bg-gray-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span class="mr-2">{entry.icon}</span>
|
<Dynamic
|
||||||
|
component={docIcon(entry.icon)}
|
||||||
|
class="mr-2 inline-block align-[-0.125em]"
|
||||||
|
/>
|
||||||
<Show
|
<Show
|
||||||
when={docSet() === "directives"}
|
when={docSet() === "directives"}
|
||||||
fallback={<span class="text-xs">{entry.title}</span>}
|
fallback={<span class="text-xs">{entry.title}</span>}
|
||||||
@@ -163,12 +176,17 @@ const DocContent: Component<{ entry: AnyEntry }> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div class="max-w-none">
|
<div class="max-w-none">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">
|
<h2 class="text-2xl font-bold text-gray-900 mb-6">
|
||||||
{e.icon} {e.title}
|
<Dynamic
|
||||||
|
component={docIcon(e.icon)}
|
||||||
|
class="inline-block align-[-0.125em]"
|
||||||
|
/>{" "}
|
||||||
|
{e.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="text-sm leading-relaxed whitespace-pre-wrap text-gray-800">
|
<article
|
||||||
{e.body}
|
class="prose prose-sm max-w-none text-gray-800"
|
||||||
</div>
|
innerHTML={parseMarkdown(e.body)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Component, createMemo, createSignal, Show } from "solid-js";
|
import { Component, createMemo, createSignal, Show } from "solid-js";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
|
import {
|
||||||
|
TbOutlineFolderOpen,
|
||||||
|
TbOutlineFolder,
|
||||||
|
TbOutlineFile,
|
||||||
|
TbOutlineChevronRight,
|
||||||
|
} from "solid-icons/tb";
|
||||||
import { type FileNode, type TocNode } from "../data-loader";
|
import { type FileNode, type TocNode } from "../data-loader";
|
||||||
import { useNavigateWithParams } from "./useNavigateWithParams";
|
import { useNavigateWithParams } from "./useNavigateWithParams";
|
||||||
import { useScrollContainer } from "../App";
|
import { useScrollContainer } from "../App";
|
||||||
@@ -61,10 +67,14 @@ export const FileTreeNode: Component<{
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<Show when={isDir}>
|
<Show when={isDir}>
|
||||||
<span class="mr-1 text-gray-400">{isExpanded() ? "📂" : "📁"}</span>
|
{isExpanded() ? (
|
||||||
|
<TbOutlineFolderOpen class="mr-1 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<TbOutlineFolder class="mr-1 text-gray-400" />
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!isDir}>
|
<Show when={!isDir}>
|
||||||
<span class="mr-1 text-gray-400">📄</span>
|
<TbOutlineFile class="mr-1 text-gray-400" />
|
||||||
</Show>
|
</Show>
|
||||||
<span class="text-sm truncate">{props.node.name}</span>
|
<span class="text-sm truncate">{props.node.name}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -151,7 +161,7 @@ export const HeadingNode: Component<{
|
|||||||
<span
|
<span
|
||||||
class={`${hasChildren ? "" : "invisible"} ${isExpanded() ? "rotate-90" : ""} mt-1 inline-block transition-transform `}
|
class={`${hasChildren ? "" : "invisible"} ${isExpanded() ? "rotate-90" : ""} mt-1 inline-block transition-transform `}
|
||||||
>
|
>
|
||||||
▶
|
<TbOutlineChevronRight class="inline-block align-[-0.125em]" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import {
|
|||||||
onCleanup,
|
onCleanup,
|
||||||
Show,
|
Show,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { Portal } from "solid-js/web";
|
import { Portal, Dynamic } from "solid-js/web";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
|
import type { IconTypes } from "solid-icons";
|
||||||
|
import { TbOutlineLink, TbOutlineSparkles } from "solid-icons/tb";
|
||||||
import { useArticleDom } from "./Article";
|
import { useArticleDom } from "./Article";
|
||||||
import { journalStreamState } from "./stores/journalStream";
|
import { journalStreamState } from "./stores/journalStream";
|
||||||
import { useJournalCompletions } from "./journal/completions";
|
import { useJournalCompletions } from "./journal/completions";
|
||||||
@@ -27,25 +29,6 @@ import {
|
|||||||
normalizePath,
|
normalizePath,
|
||||||
} from "./stores/reveal";
|
} from "./stores/reveal";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// SVG icons
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const LINK_SVG =
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" ' +
|
|
||||||
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
|
||||||
'stroke-linecap="round" stroke-linejoin="round">' +
|
|
||||||
'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>' +
|
|
||||||
'<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>' +
|
|
||||||
"</svg>";
|
|
||||||
|
|
||||||
const SPARK_SVG =
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" ' +
|
|
||||||
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
|
||||||
'stroke-linecap="round" stroke-linejoin="round">' +
|
|
||||||
'<path d="M12 2l1.5 6.5L18 7l-4.5 4.5L16 16l-4-2.5L8 16l1.5-4.5L5 7l4.5-.5z"/>' +
|
|
||||||
"</svg>";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Floating button state
|
// Floating button state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -54,7 +37,7 @@ interface FloatingButton {
|
|||||||
rect: DOMRect;
|
rect: DOMRect;
|
||||||
action: () => void;
|
action: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
svg: string;
|
icon: IconTypes;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +113,7 @@ export const RevealManager: Component = () => {
|
|||||||
text: combinedSlug,
|
text: combinedSlug,
|
||||||
}),
|
}),
|
||||||
title: "Roll spark table",
|
title: "Roll spark table",
|
||||||
svg: SPARK_SVG,
|
icon: TbOutlineSparkles,
|
||||||
color: "text-purple-400 hover:text-purple-600 hover:bg-purple-50",
|
color: "text-purple-400 hover:text-purple-600 hover:bg-purple-50",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -150,7 +133,7 @@ export const RevealManager: Component = () => {
|
|||||||
text: `${normalized}#${text}`,
|
text: `${normalized}#${text}`,
|
||||||
}),
|
}),
|
||||||
title: "Send /link to stream",
|
title: "Send /link to stream",
|
||||||
svg: LINK_SVG,
|
icon: TbOutlineLink,
|
||||||
color: "text-blue-500 hover:bg-blue-50",
|
color: "text-blue-500 hover:bg-blue-50",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -183,14 +166,15 @@ export const RevealManager: Component = () => {
|
|||||||
left: `${btn().rect.left - 20}px`,
|
left: `${btn().rect.left - 20}px`,
|
||||||
}}
|
}}
|
||||||
title={btn().title}
|
title={btn().title}
|
||||||
innerHTML={btn().svg}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
btn().action();
|
btn().action();
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => clearTimeout(hideTimer)}
|
onMouseEnter={() => clearTimeout(hideTimer)}
|
||||||
onMouseLeave={hide}
|
onMouseLeave={hide}
|
||||||
/>
|
>
|
||||||
|
<Dynamic component={btn().icon} size={12} />
|
||||||
|
</button>
|
||||||
</Portal>
|
</Portal>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, createMemo, createSignal, onMount, Show } from "solid-js";
|
import { Component, createMemo, createSignal, onMount, Show } from "solid-js";
|
||||||
import { generateToc, type FileNode, type TocNode } from "../data-loader";
|
import { generateToc, type FileNode, type TocNode } from "../data-loader";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
|
import { TbOutlineFolderOpen, TbOutlineX } from "solid-icons/tb";
|
||||||
import { FileTreeNode, HeadingNode } from "./FileTree";
|
import { FileTreeNode, HeadingNode } from "./FileTree";
|
||||||
import { isPathRevealed } from "./stores/reveal";
|
import { isPathRevealed } from "./stores/reveal";
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ interface SidebarContentProps {
|
|||||||
pathHeadings: Record<string, TocNode[]>;
|
pathHeadings: Record<string, TocNode[]>;
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isDesktop?: boolean;
|
onNavigate?: () => void;
|
||||||
onDataSourceOpen?: () => void;
|
onDataSourceOpen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,17 +58,15 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
|||||||
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100"
|
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100"
|
||||||
title="Content Source"
|
title="Content Source"
|
||||||
>
|
>
|
||||||
📂
|
<TbOutlineFolderOpen class="inline-block align-[-0.125em]" />
|
||||||
</button>
|
</button>
|
||||||
<Show when={!props.isDesktop}>
|
|
||||||
<button
|
<button
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
class="text-gray-500 hover:text-gray-700"
|
class="text-gray-500 hover:text-gray-700"
|
||||||
title="关闭"
|
title="关闭"
|
||||||
>
|
>
|
||||||
✕
|
<TbOutlineX class="inline-block align-[-0.125em]" />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +82,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
|||||||
currentPath={props.currentPath}
|
currentPath={props.currentPath}
|
||||||
pathHeadings={props.pathHeadings}
|
pathHeadings={props.pathHeadings}
|
||||||
depth={0}
|
depth={0}
|
||||||
onClose={props.onClose}
|
onClose={() => props.onNavigate?.()}
|
||||||
isHidden={isFileHidden}
|
isHidden={isFileHidden}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -150,6 +149,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
|||||||
pathHeadings={pathHeadings()}
|
pathHeadings={pathHeadings()}
|
||||||
currentPath={location.pathname}
|
currentPath={location.pathname}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
|
onNavigate={props.onClose}
|
||||||
onDataSourceOpen={props.onDataSourceOpen}
|
onDataSourceOpen={props.onDataSourceOpen}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -163,6 +163,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
|||||||
export const DesktopSidebar: Component<{
|
export const DesktopSidebar: Component<{
|
||||||
fileTree?: FileNode[];
|
fileTree?: FileNode[];
|
||||||
pathHeadings?: Record<string, TocNode[]>;
|
pathHeadings?: Record<string, TocNode[]>;
|
||||||
|
onClose: () => void;
|
||||||
onDataSourceOpen?: () => void;
|
onDataSourceOpen?: () => void;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -182,13 +183,12 @@ export const DesktopSidebar: Component<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside class="hidden md:flex flex-col w-64 shrink-0 bg-white shadow-lg overflow-hidden">
|
<aside class="hidden md:flex flex-col h-full w-64 shrink-0 bg-white border-r border-gray-200 overflow-hidden">
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
fileTree={fileTree()}
|
fileTree={fileTree()}
|
||||||
pathHeadings={pathHeadings()}
|
pathHeadings={pathHeadings()}
|
||||||
currentPath={location.pathname}
|
currentPath={location.pathname}
|
||||||
onClose={() => {}}
|
onClose={props.onClose}
|
||||||
isDesktop
|
|
||||||
onDataSourceOpen={props.onDataSourceOpen}
|
onDataSourceOpen={props.onDataSourceOpen}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { tokenize, parse, roll, applyModifier, rollFormula } from "./index";
|
||||||
|
import type { DieResult } from "./index";
|
||||||
|
|
||||||
|
describe("dice engine — tokenizer", () => {
|
||||||
|
test("tokenizes NdX", () => {
|
||||||
|
expect(tokenize("3d6")).toEqual([{ type: "dice", count: 3, sides: 6 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tokenizes bare dX as a single die", () => {
|
||||||
|
expect(tokenize("d20")).toEqual([{ type: "dice", count: 1, sides: 20 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tokenizes a pool literal", () => {
|
||||||
|
expect(tokenize("{d4,d8}")).toEqual([
|
||||||
|
{ type: "pool_start" },
|
||||||
|
{ type: "dice", count: 1, sides: 4 },
|
||||||
|
{ type: "comma" },
|
||||||
|
{ type: "dice", count: 1, sides: 8 },
|
||||||
|
{ type: "pool_end" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tokenizes a keep-highest modifier", () => {
|
||||||
|
expect(tokenize("3d6kh2")).toEqual([
|
||||||
|
{ type: "dice", count: 3, sides: 6 },
|
||||||
|
{ type: "modifier", modType: "kh", count: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores whitespace", () => {
|
||||||
|
expect(tokenize(" 3d6 + 5 ")).toEqual([
|
||||||
|
{ type: "dice", count: 3, sides: 6 },
|
||||||
|
{ type: "plus" },
|
||||||
|
{ type: "number", value: 5 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dice engine — parser", () => {
|
||||||
|
test("builds one pool for NdX", () => {
|
||||||
|
const pools = parse(tokenize("3d6"));
|
||||||
|
expect(pools).toHaveLength(1);
|
||||||
|
expect(pools[0].dice).toHaveLength(3);
|
||||||
|
expect(pools[0].dice.every((d) => d.sides === 6)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds a combined pool list for +", () => {
|
||||||
|
const pools = parse(tokenize("3d6+5"));
|
||||||
|
expect(pools).toHaveLength(2);
|
||||||
|
expect(pools[1].dice[0]).toMatchObject({ sides: 0, value: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marks a subtracted pool negative", () => {
|
||||||
|
const pools = parse(tokenize("3d6-2"));
|
||||||
|
expect(pools).toHaveLength(2);
|
||||||
|
expect(pools[1].isNegative).toBe(true);
|
||||||
|
expect(pools[1].dice[0].isNegative).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dice engine — applyModifier", () => {
|
||||||
|
const dice = (...values: number[]): DieResult[] =>
|
||||||
|
values.map((value) => ({ sides: 6, value, isNegative: false }));
|
||||||
|
|
||||||
|
test("keeps the highest N", () => {
|
||||||
|
const { keptRolls, droppedRolls } = applyModifier(dice(2, 6, 4), {
|
||||||
|
type: "kh",
|
||||||
|
count: 2,
|
||||||
|
});
|
||||||
|
expect(keptRolls.map((r) => r.value).sort()).toEqual([4, 6]);
|
||||||
|
expect(droppedRolls.map((r) => r.value)).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops the lowest N", () => {
|
||||||
|
const { keptRolls, droppedRolls } = applyModifier(dice(2, 6, 4, 1), {
|
||||||
|
type: "dl",
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
expect(keptRolls.map((r) => r.value).sort()).toEqual([2, 4, 6]);
|
||||||
|
expect(droppedRolls.map((r) => r.value)).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps all when there is no modifier", () => {
|
||||||
|
const { keptRolls, droppedRolls } = applyModifier(dice(1, 2), undefined);
|
||||||
|
expect(keptRolls).toHaveLength(2);
|
||||||
|
expect(droppedRolls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dice engine — roll", () => {
|
||||||
|
test("rolls 3d6 within range", () => {
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
const result = roll("3d6");
|
||||||
|
expect(result.total).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(result.total).toBeLessThanOrEqual(18);
|
||||||
|
expect(result.pools).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rolls d20 within range", () => {
|
||||||
|
const result = roll("d20");
|
||||||
|
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(result.total).toBeLessThanOrEqual(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds fixed numbers to the total", () => {
|
||||||
|
const result = roll("3d6+5");
|
||||||
|
expect(result.total).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(result.total).toBeLessThanOrEqual(23);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keep-highest keeps N dice and totals only those", () => {
|
||||||
|
const result = roll("3d6kh2");
|
||||||
|
const [pool] = result.pools;
|
||||||
|
expect(pool.keptRolls).toHaveLength(2);
|
||||||
|
expect(pool.droppedRolls).toHaveLength(1);
|
||||||
|
expect(result.total).toBe(pool.subtotal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits matching HTML and plain-text detail", () => {
|
||||||
|
const result = roll("2d6");
|
||||||
|
expect(result.detail).toContain(String(result.total));
|
||||||
|
expect(result.detail).toContain("<strong>");
|
||||||
|
expect(result.plainDetail).toContain(String(result.total));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dice engine — rollFormula", () => {
|
||||||
|
test("normalizes a successful roll", () => {
|
||||||
|
const result = rollFormula("3d6");
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.formula).toBe("3d6");
|
||||||
|
expect(result.result.total).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(result.result.pools[0].rolls).toHaveLength(3);
|
||||||
|
expect(result.result.pools[0].keptRolls).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports pool literals and modifiers", () => {
|
||||||
|
const result = rollFormula("{d4,d8}kh2-5");
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.result.pools.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Dice engine — shared infrastructure for every dice-rolling feature
|
||||||
|
* (`:md-dice`, spark tables, journal `/roll`, and dice inside variable
|
||||||
|
* expressions).
|
||||||
|
*
|
||||||
|
* `rollFormula` is the normalized entry point; the tokenizer/parser/roller are
|
||||||
|
* exported for tests and for callers that need the structured pool results.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { rollFormula } from "./rollFormula";
|
||||||
|
export type { DiceRollerResult } from "./rollFormula";
|
||||||
|
|
||||||
|
export { tokenize, parse } from "./parser";
|
||||||
|
export { roll, rollDie, rollDicePool, applyModifier } from "./roller";
|
||||||
|
export type {
|
||||||
|
DieResult,
|
||||||
|
DicePool,
|
||||||
|
RollResult,
|
||||||
|
DicePoolResult,
|
||||||
|
Token,
|
||||||
|
} from "./types";
|
||||||
+8
-33
@@ -1,4 +1,4 @@
|
|||||||
import { roll as rollDice } from "./dice-engine";
|
import { roll as rollDice } from "./roller";
|
||||||
|
|
||||||
export interface DiceRollerResult {
|
export interface DiceRollerResult {
|
||||||
formula: string;
|
formula: string;
|
||||||
@@ -16,19 +16,14 @@ export interface DiceRollerResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 骰子引擎 Hook
|
|
||||||
* 提供高级骰子功能:骰池、修饰符、组合等
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* 执行掷骰
|
* 执行掷骰
|
||||||
* @param formula 骰子公式,支持:
|
*
|
||||||
* - 3d6: 标准骰子
|
* Supports pools, modifiers and combinations. For example:
|
||||||
* - {d4, d8, 2d6}: 骰池字面量
|
* - `3d6`: standard dice
|
||||||
* - kh2/dl1: 修饰符
|
* - `{d4, d8, 2d6}`: pool literal
|
||||||
* - +: 组合骰池
|
* - `kh2` / `dl1`: keep-high / drop-low modifiers
|
||||||
* - -: 负数组合
|
* - `+` / `-`: combine pools; a bare number is a fixed value
|
||||||
* - 5: 固定数字
|
|
||||||
*/
|
*/
|
||||||
export function rollFormula(formula: string): DiceRollerResult {
|
export function rollFormula(formula: string): DiceRollerResult {
|
||||||
try {
|
try {
|
||||||
@@ -63,24 +58,4 @@ export function rollFormula(formula: string): DiceRollerResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 简化版掷骰,返回 HTML 格式结果
|
|
||||||
* 格式:[1] [2] [3] = <strong>6</strong>
|
|
||||||
*/
|
|
||||||
export function rollSimple(formula: string): {
|
|
||||||
text: string;
|
|
||||||
isHtml?: boolean;
|
|
||||||
} {
|
|
||||||
try {
|
|
||||||
const result = rollDice(formula);
|
|
||||||
return {
|
|
||||||
text: result.detail,
|
|
||||||
isHtml: true, // detail 包含 HTML
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
text: `错误:${e instanceof Error ? e.message : String(e)}`,
|
|
||||||
isHtml: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -48,10 +48,9 @@ import mdBgRaw from "../doc-entries/md-bg.md";
|
|||||||
import mdBorderRaw from "../doc-entries/md-border.md";
|
import mdBorderRaw from "../doc-entries/md-border.md";
|
||||||
import mdEmbedRaw from "../doc-entries/md-embed.md";
|
import mdEmbedRaw from "../doc-entries/md-embed.md";
|
||||||
import mdDeckRaw from "../doc-entries/md-deck.md";
|
import mdDeckRaw from "../doc-entries/md-deck.md";
|
||||||
|
import mdPrintRaw from "../doc-entries/md-print.md";
|
||||||
import mdYarnRaw from "../doc-entries/md-yarn-spinner.md";
|
import mdYarnRaw from "../doc-entries/md-yarn-spinner.md";
|
||||||
|
|
||||||
import mdCommanderRaw from "../doc-entries/md-commander.md";
|
|
||||||
|
|
||||||
const rawDocuments: string[] = [
|
const rawDocuments: string[] = [
|
||||||
mdDiceRaw,
|
mdDiceRaw,
|
||||||
mdTableRaw,
|
mdTableRaw,
|
||||||
@@ -62,9 +61,8 @@ const rawDocuments: string[] = [
|
|||||||
mdBorderRaw,
|
mdBorderRaw,
|
||||||
mdEmbedRaw,
|
mdEmbedRaw,
|
||||||
mdDeckRaw,
|
mdDeckRaw,
|
||||||
|
mdPrintRaw,
|
||||||
mdYarnRaw,
|
mdYarnRaw,
|
||||||
|
|
||||||
mdCommanderRaw,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let _entries: DocEntry[] | null = null;
|
let _entries: DocEntry[] | null = null;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Icon registry for built-in doc entries.
|
||||||
|
*
|
||||||
|
* Doc-entry frontmatter `icon:` values are semantic keys (e.g. `dice`), not
|
||||||
|
* glyphs — YAML can't reference a component. This maps each key to a Tabler
|
||||||
|
* icon component; unknown keys fall back to a generic file icon.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IconTypes } from "solid-icons";
|
||||||
|
import {
|
||||||
|
TbOutlineCards,
|
||||||
|
TbOutlineDice5,
|
||||||
|
TbOutlineFile,
|
||||||
|
TbOutlineFileText,
|
||||||
|
TbOutlineGitBranch,
|
||||||
|
TbOutlineHash,
|
||||||
|
TbOutlineLink,
|
||||||
|
TbOutlineMapPin,
|
||||||
|
TbOutlinePhoto,
|
||||||
|
TbOutlinePrinter,
|
||||||
|
TbOutlineSparkles,
|
||||||
|
TbOutlineSquare,
|
||||||
|
TbOutlineTable,
|
||||||
|
TbOutlineTheater,
|
||||||
|
TbOutlineTypography,
|
||||||
|
} from "solid-icons/tb";
|
||||||
|
|
||||||
|
const DOC_ICONS: Record<string, IconTypes> = {
|
||||||
|
dice: TbOutlineDice5,
|
||||||
|
table: TbOutlineTable,
|
||||||
|
link: TbOutlineLink,
|
||||||
|
pins: TbOutlineMapPin,
|
||||||
|
font: TbOutlineTypography,
|
||||||
|
background: TbOutlinePhoto,
|
||||||
|
border: TbOutlineSquare,
|
||||||
|
embed: TbOutlineFileText,
|
||||||
|
deck: TbOutlineCards,
|
||||||
|
print: TbOutlinePrinter,
|
||||||
|
yarn: TbOutlineGitBranch,
|
||||||
|
gm: TbOutlineTheater,
|
||||||
|
player: TbOutlineDice5,
|
||||||
|
roll: TbOutlineDice5,
|
||||||
|
set: TbOutlineHash,
|
||||||
|
spark: TbOutlineSparkles,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve a frontmatter icon key to a Tabler icon component. */
|
||||||
|
export function docIcon(key: string): IconTypes {
|
||||||
|
return DOC_ICONS[key] ?? TbOutlineFile;
|
||||||
|
}
|
||||||
+1
-27
@@ -8,7 +8,7 @@ import "./md-border";
|
|||||||
import "./md-font";
|
import "./md-font";
|
||||||
import "./md-embed";
|
import "./md-embed";
|
||||||
import "./md-deck";
|
import "./md-deck";
|
||||||
import "./md-commander/index";
|
import "./md-print";
|
||||||
import "./md-yarn-spinner";
|
import "./md-yarn-spinner";
|
||||||
|
|
||||||
// 导出组件
|
// 导出组件
|
||||||
@@ -33,29 +33,3 @@ export type { FontProps } from "./md-font";
|
|||||||
export type { BorderProps } from "./md-border";
|
export type { BorderProps } from "./md-border";
|
||||||
export type { EmbedProps } from "./md-embed";
|
export type { EmbedProps } from "./md-embed";
|
||||||
export type { YarnSpinnerProps } from "./md-yarn-spinner";
|
export type { YarnSpinnerProps } from "./md-yarn-spinner";
|
||||||
|
|
||||||
// 导出 md-commander 相关
|
|
||||||
export type {
|
|
||||||
MdCommanderProps,
|
|
||||||
MdCommanderCommand,
|
|
||||||
MdCommanderCommandMap,
|
|
||||||
MdCommanderParameter,
|
|
||||||
MdCommanderOption,
|
|
||||||
MdCommanderOptionType,
|
|
||||||
CommanderEntry,
|
|
||||||
CompletionItem,
|
|
||||||
TrackerItem,
|
|
||||||
TrackerAttribute,
|
|
||||||
TrackerAttributeType,
|
|
||||||
TrackerCommand,
|
|
||||||
TrackerViewMode,
|
|
||||||
} from "./md-commander/types";
|
|
||||||
export { TabBar } from "./md-commander/TabBar";
|
|
||||||
export { TrackerView } from "./md-commander/TrackerView";
|
|
||||||
export { CommanderEntries } from "./md-commander/CommanderEntries";
|
|
||||||
export { CommanderInput } from "./md-commander/CommanderInput";
|
|
||||||
export { useCommander } from "./md-commander/hooks";
|
|
||||||
export {
|
|
||||||
initializeCommands,
|
|
||||||
getCommands,
|
|
||||||
} from "./md-commander/stores/commandsStore";
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, For, Show, createEffect, onMount, onCleanup } from "solid-js";
|
import { Component, For, Show, createEffect, onMount, onCleanup } from "solid-js";
|
||||||
|
import { Dynamic } from "solid-js/web";
|
||||||
import type { CompletionItem } from "./command-parser";
|
import type { CompletionItem } from "./command-parser";
|
||||||
import type { CompletionsState } from "./completions";
|
import type { CompletionsState } from "./completions";
|
||||||
|
|
||||||
@@ -102,6 +103,11 @@ export const CompletionsDropdown: Component<CompletionsDropdownProps> = (
|
|||||||
{item.kind === "command" ? "cmd" : "val"}
|
{item.kind === "command" ? "cmd" : "val"}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={item.icon}>
|
||||||
|
{(icon) => (
|
||||||
|
<Dynamic component={icon()} size={14} class="shrink-0" />
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<span class="font-mono truncate flex-1">{item.label}</span>
|
<span class="font-mono truncate flex-1">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,27 +1,90 @@
|
|||||||
/**
|
/**
|
||||||
* ConnectDialog — name + role picker to join a session
|
* ConnectDialog — broker connection + session lobby (list / create / join)
|
||||||
|
*
|
||||||
|
* The broker connection and the active session are decoupled: this dialog
|
||||||
|
* connects to the broker, which makes the session list available, and joining
|
||||||
|
* or creating a session is a separate step that reuses that connection.
|
||||||
*/
|
*/
|
||||||
import { Component, createSignal, onMount, Show } from "solid-js";
|
import { Component, createSignal, For, onMount, Show } from "solid-js";
|
||||||
|
import { TbOutlineTrash } from "solid-icons/tb";
|
||||||
import {
|
import {
|
||||||
connectStream,
|
connectBroker,
|
||||||
hydrateFromServer,
|
createSession,
|
||||||
useJournalStream,
|
joinSession,
|
||||||
|
resolveBrokerUrl,
|
||||||
|
sessions,
|
||||||
setMyName,
|
setMyName,
|
||||||
setMyRole,
|
setMyRole,
|
||||||
setSessionId,
|
useJournalStream,
|
||||||
} from "../stores/journalStream";
|
} from "../stores/journalStream";
|
||||||
|
import { DeleteSessionDialog } from "./DeleteSessionDialog";
|
||||||
|
|
||||||
export const ConnectDialog: Component = () => {
|
export const ConnectDialog: Component = () => {
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
|
const manifest = sessions;
|
||||||
const [playerName, setPlayerName] = createSignal(stream.myName);
|
const [playerName, setPlayerName] = createSignal(stream.myName);
|
||||||
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
|
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
|
||||||
stream.myRole,
|
stream.myRole,
|
||||||
);
|
);
|
||||||
const [connecting, setConnecting] = createSignal(false);
|
const [newSessionName, setNewSessionName] = createSignal("");
|
||||||
|
const [busy, setBusy] = createSignal(false);
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
const [autoJoined, setAutoJoined] = createSignal(false);
|
const [autoJoined, setAutoJoined] = createSignal(false);
|
||||||
|
const [sessionToDelete, setSessionToDelete] = createSignal<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Auto-join when URL has both session and player params
|
const ensureBroker = async () => {
|
||||||
|
if (stream.connected) return;
|
||||||
|
await connectBroker(resolveBrokerUrl());
|
||||||
|
};
|
||||||
|
|
||||||
|
const identity = () => ({
|
||||||
|
name: playerName().trim() || stream.myName,
|
||||||
|
role: role(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyIdentity = () => {
|
||||||
|
const { name, role: r } = identity();
|
||||||
|
setMyName(name);
|
||||||
|
setMyRole(r);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = async (sessionId: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
applyIdentity();
|
||||||
|
await ensureBroker();
|
||||||
|
await joinSession(sessionId, identity());
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Connection failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const name = newSessionName().trim();
|
||||||
|
if (!name) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
applyIdentity();
|
||||||
|
await ensureBroker();
|
||||||
|
const id = await createSession(name);
|
||||||
|
if (id) await joinSession(id, identity());
|
||||||
|
setNewSessionName("");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Connection failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-join when the URL carries session + player + autojoin params;
|
||||||
|
// otherwise enter the lobby so the session list is available up front.
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const params = new URL(window.location.href).searchParams;
|
const params = new URL(window.location.href).searchParams;
|
||||||
if (
|
if (
|
||||||
@@ -32,51 +95,26 @@ export const ConnectDialog: Component = () => {
|
|||||||
setPlayerName(params.get("player") || "");
|
setPlayerName(params.get("player") || "");
|
||||||
setRole("player");
|
setRole("player");
|
||||||
setAutoJoined(true);
|
setAutoJoined(true);
|
||||||
handleConnect();
|
handleJoin(params.get("session")!);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
ensureBroker().catch((e) => {
|
||||||
|
setError(e instanceof Error ? e.message : "Connection failed");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleConnect = async () => {
|
|
||||||
const name = playerName().trim() || stream.myName;
|
|
||||||
if (!name) return;
|
|
||||||
|
|
||||||
// In dev, the MQTT broker lives on the CLI server (port 3000), not the
|
|
||||||
// rsbuild dev server. In production, the CLI serves everything itself.
|
|
||||||
const brokerUrl =
|
|
||||||
process.env.NODE_ENV === "development"
|
|
||||||
? `ws://localhost:3000`
|
|
||||||
: `ws://${window.location.host}`;
|
|
||||||
|
|
||||||
setConnecting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
setMyName(name);
|
|
||||||
setMyRole(role());
|
|
||||||
const sessionId = stream.sessionId || "default";
|
|
||||||
setSessionId(sessionId);
|
|
||||||
await hydrateFromServer(sessionId);
|
|
||||||
await connectStream(sessionId, brokerUrl);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : "Connection failed";
|
|
||||||
setError(msg);
|
|
||||||
} finally {
|
|
||||||
setConnecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
when={!autoJoined() || error()}
|
when={!autoJoined() || error()}
|
||||||
fallback={
|
fallback={
|
||||||
<p class="text-sm text-gray-500 text-center">
|
<p class="text-sm text-gray-500 text-center">
|
||||||
{connecting() ? "正在加入会话…" : "已加入!"}
|
{busy() ? "正在加入会话…" : "已加入!"}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="w-full max-w-sm space-y-3">
|
<div class="w-full max-w-sm space-y-3">
|
||||||
<p class="text-sm text-gray-600 text-center">
|
<p class="text-sm text-gray-600 text-center">
|
||||||
请输入你的名字和角色来加入会话。
|
输入你的名字和角色,选择或新建一个会话。
|
||||||
<br />
|
<br />
|
||||||
<span class="text-xs text-gray-400">
|
<span class="text-xs text-gray-400">
|
||||||
连接到 {window.location.host}
|
连接到 {window.location.host}
|
||||||
@@ -86,7 +124,7 @@ export const ConnectDialog: Component = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={playerName()}
|
value={playerName()}
|
||||||
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
|
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||||
placeholder="你的名字"
|
placeholder="你的名字"
|
||||||
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||||
autofocus
|
autofocus
|
||||||
@@ -106,17 +144,80 @@ export const ConnectDialog: Component = () => {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Session list */}
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-xs text-gray-500">会话</p>
|
||||||
|
<div class="max-h-40 overflow-y-auto border border-gray-200 rounded divide-y divide-gray-100">
|
||||||
|
<For each={Object.entries(manifest().sessions)}>
|
||||||
|
{([id, meta]) => (
|
||||||
|
<div class="flex items-center">
|
||||||
<button
|
<button
|
||||||
onClick={handleConnect}
|
onClick={() => handleJoin(id)}
|
||||||
disabled={connecting() || !playerName().trim()}
|
disabled={busy()}
|
||||||
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium
|
class="flex-1 min-w-0 text-left px-2 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50 truncate"
|
||||||
hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{connecting() ? "连接中..." : "加入"}
|
{meta.name || id}
|
||||||
</button>
|
</button>
|
||||||
|
<Show when={role() === "gm"}>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setSessionToDelete({ id, name: meta.name || id })
|
||||||
|
}
|
||||||
|
disabled={busy()}
|
||||||
|
class="shrink-0 px-2 py-1.5 text-[10px] text-gray-300 hover:text-red-500 disabled:opacity-50"
|
||||||
|
title="删除会话"
|
||||||
|
>
|
||||||
|
<TbOutlineTrash />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={Object.keys(manifest().sessions).length === 0}>
|
||||||
|
<p class="px-2 py-2 text-xs text-gray-400">
|
||||||
|
{stream.connected ? "还没有会话" : "正在加载会话…"}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create a new session and join it (GM only) */}
|
||||||
|
<Show when={role() === "gm"}>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newSessionName()}
|
||||||
|
onInput={(e) => setNewSessionName(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||||
|
placeholder="新会话名称"
|
||||||
|
class="flex-1 border border-gray-300 rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={busy() || !newSessionName().trim()}
|
||||||
|
class="bg-blue-600 text-white rounded px-3 py-1.5 text-sm font-medium
|
||||||
|
hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
新建并加入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<p class="text-red-500 text-sm text-center">{error()}</p>
|
<p class="text-red-500 text-sm text-center">{error()}</p>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={sessionToDelete()}>
|
||||||
|
{(target) => (
|
||||||
|
<DeleteSessionDialog
|
||||||
|
sessionId={target().id}
|
||||||
|
sessionName={target().name}
|
||||||
|
active={stream.sessionId === target().id}
|
||||||
|
onClose={() => setSessionToDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
/**
|
|
||||||
* ConnectionBar — session picker, player info (connected state only)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, createSignal, Show, For } from "solid-js";
|
|
||||||
import {
|
|
||||||
hydrateFromServer,
|
|
||||||
useJournalStream,
|
|
||||||
sessions,
|
|
||||||
createSession,
|
|
||||||
deleteSession,
|
|
||||||
disconnectStream,
|
|
||||||
setSessionId,
|
|
||||||
} from "../stores/journalStream";
|
|
||||||
import { journalSetState } from "../stores/journalStream";
|
|
||||||
|
|
||||||
export const ConnectionBar: Component = () => {
|
|
||||||
const stream = useJournalStream();
|
|
||||||
const [newSessionName, setNewSessionName] = createSignal("");
|
|
||||||
const [showNewSession, setShowNewSession] = createSignal(false);
|
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
|
|
||||||
const manifest = sessions;
|
|
||||||
|
|
||||||
const handleCreateSession = () => {
|
|
||||||
const name = newSessionName().trim();
|
|
||||||
if (!name) return;
|
|
||||||
const id = createSession(name);
|
|
||||||
if (id) {
|
|
||||||
setSessionId(id);
|
|
||||||
hydrateFromServer(id).catch(() => {});
|
|
||||||
}
|
|
||||||
setNewSessionName("");
|
|
||||||
setShowNewSession(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteSession = (id: string) => {
|
|
||||||
if (confirm(`确定要删除会话 "${id}"?此操作不可撤销。`)) {
|
|
||||||
deleteSession(id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSessionSelect = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await hydrateFromServer(id);
|
|
||||||
setSessionId(id);
|
|
||||||
} catch (e) {
|
|
||||||
setError("无法加载会话");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
|
||||||
disconnectStream();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="border-b border-gray-200 px-3 py-2 space-y-2 text-sm">
|
|
||||||
{/* Status row */}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="w-2 h-2 rounded-full bg-green-500" />
|
|
||||||
<span class="text-xs text-gray-500">已连接</span>
|
|
||||||
<Show when={stream.sessionId}>
|
|
||||||
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
|
|
||||||
{stream.sessionName || stream.sessionId}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<div class="flex-1" />
|
|
||||||
<Show when={stream.myName !== "gm"}>
|
|
||||||
<span class="text-xs text-gray-400">扮演 {stream.myName}</span>
|
|
||||||
</Show>
|
|
||||||
<button
|
|
||||||
onClick={handleDisconnect}
|
|
||||||
class="text-xs text-gray-400 hover:text-red-500"
|
|
||||||
title="断开连接"
|
|
||||||
>
|
|
||||||
⏻
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GM session management */}
|
|
||||||
<Show when={stream.myName === "gm"}>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<span class="text-xs text-gray-500">会话列表:</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowNewSession((v) => !v)}
|
|
||||||
class="text-xs text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
+ 新建
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={showNewSession()}>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newSessionName()}
|
|
||||||
onInput={(e) => setNewSessionName(e.currentTarget.value)}
|
|
||||||
placeholder="会话名称"
|
|
||||||
class="flex-1 border border-gray-300 rounded px-2 py-0.5 text-xs"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleCreateSession()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleCreateSession}
|
|
||||||
class="bg-green-600 text-white rounded px-2 py-0.5 text-xs hover:bg-green-700"
|
|
||||||
>
|
|
||||||
Create Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<For each={Object.entries(manifest().sessions)}>
|
|
||||||
{([id, meta]) => (
|
|
||||||
<div
|
|
||||||
class={`flex items-center gap-1 px-1 py-0.5 rounded cursor-pointer text-xs ${
|
|
||||||
id === stream.sessionId
|
|
||||||
? "bg-blue-100 text-blue-800"
|
|
||||||
: "hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex-1 truncate"
|
|
||||||
onClick={() => handleSessionSelect(id)}
|
|
||||||
>
|
|
||||||
{meta.name || id}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteSession(id);
|
|
||||||
}}
|
|
||||||
class="text-gray-400 hover:text-red-500 text-xs"
|
|
||||||
title="删除会话"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={error()}>
|
|
||||||
<p class="text-red-500 text-xs">{error()}</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
* CreateSessionDialog — modal for naming a new session
|
* CreateSessionDialog — modal for naming a new session
|
||||||
*/
|
*/
|
||||||
import { Component, createSignal, onMount } from "solid-js";
|
import { Component, createSignal, onMount } from "solid-js";
|
||||||
import { createSession, setSessionId, hydrateFromServer } from "../stores/journalStream";
|
import { TbOutlineX } from "solid-icons/tb";
|
||||||
|
import { createSession, joinSession } from "../stores/journalStream";
|
||||||
|
|
||||||
interface CreateSessionDialogProps {
|
interface CreateSessionDialogProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -19,15 +20,16 @@ export const CreateSessionDialog: Component<CreateSessionDialogProps> = (
|
|||||||
const trimmed = name().trim();
|
const trimmed = name().trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
const id = createSession(trimmed);
|
try {
|
||||||
if (id) {
|
const id = await createSession(trimmed);
|
||||||
setSessionId(id);
|
if (id) await joinSession(id);
|
||||||
// Hydrate the new (empty) session so the UI reflects it immediately
|
} catch (e) {
|
||||||
await hydrateFromServer(id).catch(() => {});
|
console.error("[journal] failed to create session:", e);
|
||||||
}
|
} finally {
|
||||||
setName("");
|
setName("");
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
props.onClose();
|
props.onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => inputRef?.focus());
|
onMount(() => inputRef?.focus());
|
||||||
@@ -47,7 +49,7 @@ export const CreateSessionDialog: Component<CreateSessionDialogProps> = (
|
|||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
class="text-gray-400 hover:text-gray-600 text-sm"
|
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||||
>
|
>
|
||||||
✕
|
<TbOutlineX />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500">输入新会话的名称。</p>
|
<p class="text-xs text-gray-500">输入新会话的名称。</p>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* DeleteSessionDialog — destructive confirmation before removing a session.
|
||||||
|
*
|
||||||
|
* Deletion removes the session directory on the server (meta + full message
|
||||||
|
* log), so the warning spells out that it cannot be undone.
|
||||||
|
*/
|
||||||
|
import { Component, createSignal, Show } from "solid-js";
|
||||||
|
import { TbOutlineX } from "solid-icons/tb";
|
||||||
|
import { deleteSession } from "../stores/journalStream";
|
||||||
|
|
||||||
|
interface DeleteSessionDialogProps {
|
||||||
|
sessionId: string;
|
||||||
|
sessionName: string;
|
||||||
|
/** Whether this is the session the user is currently in. */
|
||||||
|
active: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteSessionDialog: Component<DeleteSessionDialogProps> = (
|
||||||
|
props,
|
||||||
|
) => {
|
||||||
|
const [deleting, setDeleting] = createSignal(false);
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await deleteSession(props.sessionId);
|
||||||
|
props.onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "删除失败");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/20"
|
||||||
|
onClick={props.onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg border border-gray-200 shadow-xl w-[300px] p-4 space-y-3"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-red-600">删除会话</h3>
|
||||||
|
<button
|
||||||
|
onClick={props.onClose}
|
||||||
|
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||||
|
>
|
||||||
|
<TbOutlineX />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600">
|
||||||
|
确定要删除会话{" "}
|
||||||
|
<span class="font-semibold">{props.sessionName}</span>
|
||||||
|
(<span class="font-mono">{props.sessionId}</span>)吗?
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-red-500">
|
||||||
|
此操作不可恢复:该会话的全部消息记录、玩家名单和派生状态都会从服务器删除。
|
||||||
|
{props.active && " 你当前正在该会话中,删除后将返回会话列表。"}
|
||||||
|
</p>
|
||||||
|
<Show when={error()}>
|
||||||
|
<p class="text-xs text-red-500">{error()}</p>
|
||||||
|
</Show>
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={props.onClose}
|
||||||
|
class="px-3 py-1 text-xs text-gray-600 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting()}
|
||||||
|
class="bg-red-600 text-white rounded px-3 py-1 text-xs font-medium hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting() ? "删除中…" : "删除"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
* the journal input area. Toggled by clicking the truncated error text.
|
* the journal input area. Toggled by clicking the truncated error text.
|
||||||
*/
|
*/
|
||||||
import { Component, Show } from "solid-js";
|
import { Component, Show } from "solid-js";
|
||||||
|
import { TbOutlineX } from "solid-icons/tb";
|
||||||
|
|
||||||
interface ErrorPopupProps {
|
interface ErrorPopupProps {
|
||||||
message: string | null;
|
message: string | null;
|
||||||
@@ -21,7 +22,7 @@ export const ErrorPopup: Component<ErrorPopupProps> = (props) => {
|
|||||||
class="text-red-400 hover:text-red-600 text-xs leading-none ml-2 shrink-0"
|
class="text-red-400 hover:text-red-600 text-xs leading-none ml-2 shrink-0"
|
||||||
title="关闭"
|
title="关闭"
|
||||||
>
|
>
|
||||||
✕
|
<TbOutlineX />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* InviteDialog — input player name, copy invite link
|
* InviteDialog — input player name, copy invite link
|
||||||
*/
|
*/
|
||||||
import { Component, createSignal } from "solid-js";
|
import { Component, createSignal } from "solid-js";
|
||||||
|
import { TbOutlineX } from "solid-icons/tb";
|
||||||
import { useJournalStream } from "../stores/journalStream";
|
import { useJournalStream } from "../stores/journalStream";
|
||||||
|
|
||||||
interface InviteDialogProps {
|
interface InviteDialogProps {
|
||||||
@@ -51,7 +52,7 @@ export const InviteDialog: Component<InviteDialogProps> = (props) => {
|
|||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
class="text-gray-400 hover:text-gray-600 text-sm"
|
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||||
>
|
>
|
||||||
✕
|
<TbOutlineX />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500">
|
<p class="text-xs text-gray-500">
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* JournalHeader — status dot, player name, session dropdown, disconnect & close
|
* JournalHeader — status dot, player name, session dropdown, leave & close
|
||||||
*/
|
*/
|
||||||
import { Component, Show } from "solid-js";
|
import { Component, Show } from "solid-js";
|
||||||
import { useJournalStream, disconnectStream } from "../stores/journalStream";
|
import {
|
||||||
|
TbOutlineArrowLeft,
|
||||||
|
TbOutlineLayoutSidebarRightCollapse,
|
||||||
|
} from "solid-icons/tb";
|
||||||
|
import { useJournalStream, leaveSession } from "../stores/journalStream";
|
||||||
import { SessionDropdown } from "./SessionDropdown";
|
import { SessionDropdown } from "./SessionDropdown";
|
||||||
|
|
||||||
interface JournalHeaderProps {
|
interface JournalHeaderProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreateSession: () => void;
|
onCreateSession: () => void;
|
||||||
|
onDeleteSession: (id: string, name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
|
|
||||||
const handleDisconnect = () => disconnectStream();
|
const handleLeave = () => leaveSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
|
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
|
||||||
@@ -32,7 +37,9 @@ export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
|||||||
<Show
|
<Show
|
||||||
when={stream.connected && stream.sessionId}
|
when={stream.connected && stream.sessionId}
|
||||||
fallback={
|
fallback={
|
||||||
<h2 class="text-sm font-semibold text-gray-700 truncate">未连接</h2>
|
<h2 class="text-sm font-semibold text-gray-700 truncate">
|
||||||
|
{stream.connected ? "选择会话" : "未连接"}
|
||||||
|
</h2>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
@@ -52,19 +59,22 @@ export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
|||||||
</p>
|
</p>
|
||||||
{/* Clickable subtitle — session dropdown (GM only) */}
|
{/* Clickable subtitle — session dropdown (GM only) */}
|
||||||
<Show when={stream.myRole === "gm"}>
|
<Show when={stream.myRole === "gm"}>
|
||||||
<SessionDropdown onCreate={props.onCreateSession} />
|
<SessionDropdown
|
||||||
|
onCreate={props.onCreateSession}
|
||||||
|
onDelete={props.onDeleteSession}
|
||||||
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<Show when={stream.connected}>
|
<Show when={stream.sessionId}>
|
||||||
<button
|
<button
|
||||||
onClick={handleDisconnect}
|
onClick={handleLeave}
|
||||||
class="text-xs text-gray-400 hover:text-red-500 px-1"
|
class="text-xs text-gray-400 hover:text-blue-500 px-1"
|
||||||
title="断开连接"
|
title="返回会话列表"
|
||||||
>
|
>
|
||||||
⏻
|
<TbOutlineArrowLeft />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
<button
|
||||||
@@ -72,7 +82,7 @@ export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
|||||||
class="text-gray-400 hover:text-gray-600 text-sm px-1"
|
class="text-gray-400 hover:text-gray-600 text-sm px-1"
|
||||||
title="隐藏面板"
|
title="隐藏面板"
|
||||||
>
|
>
|
||||||
▸
|
<TbOutlineLayoutSidebarRightCollapse />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -109,8 +109,6 @@ export const JournalInput: Component = () => {
|
|||||||
command: raw,
|
command: raw,
|
||||||
sparkTables: comp.data.sparkTables,
|
sparkTables: comp.data.sparkTables,
|
||||||
variables: stream.variables,
|
variables: stream.variables,
|
||||||
declarations: comp.data.declarations,
|
|
||||||
tagModifiers: comp.data.tagModifiers,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// dispatchCommand already set the shared error if it failed.
|
// dispatchCommand already set the shared error if it failed.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { JournalHeader } from "./JournalHeader";
|
|||||||
import { ConnectDialog } from "./ConnectDialog";
|
import { ConnectDialog } from "./ConnectDialog";
|
||||||
import { InviteDialog } from "./InviteDialog";
|
import { InviteDialog } from "./InviteDialog";
|
||||||
import { CreateSessionDialog } from "./CreateSessionDialog";
|
import { CreateSessionDialog } from "./CreateSessionDialog";
|
||||||
|
import { DeleteSessionDialog } from "./DeleteSessionDialog";
|
||||||
import { VariableView } from "./VariableView";
|
import { VariableView } from "./VariableView";
|
||||||
|
|
||||||
export interface JournalPanelProps {
|
export interface JournalPanelProps {
|
||||||
@@ -26,6 +27,10 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
const [showInvite, setShowInvite] = createSignal(false);
|
const [showInvite, setShowInvite] = createSignal(false);
|
||||||
const [showCreateSession, setShowCreateSession] = createSignal(false);
|
const [showCreateSession, setShowCreateSession] = createSignal(false);
|
||||||
|
const [sessionToDelete, setSessionToDelete] = createSignal<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
const [viewMode, setViewMode] = createSignal<"stream" | "variables">("stream");
|
const [viewMode, setViewMode] = createSignal<"stream" | "variables">("stream");
|
||||||
|
|
||||||
// Player list (exclude gm and observer)
|
// Player list (exclude gm and observer)
|
||||||
@@ -56,10 +61,11 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||||||
<JournalHeader
|
<JournalHeader
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
onCreateSession={() => setShowCreateSession(true)}
|
onCreateSession={() => setShowCreateSession(true)}
|
||||||
|
onDeleteSession={(id, name) => setSessionToDelete({ id, name })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Player list tabs */}
|
{/* Player list tabs */}
|
||||||
<Show when={stream.connected}>
|
<Show when={stream.connected && stream.sessionId}>
|
||||||
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
|
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
|
||||||
<For each={playerEntries()}>
|
<For each={playerEntries()}>
|
||||||
{([name]) => (
|
{([name]) => (
|
||||||
@@ -79,7 +85,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={stream.connected}
|
when={stream.connected && stream.sessionId}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
|
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
|
||||||
<ConnectDialog />
|
<ConnectDialog />
|
||||||
@@ -126,6 +132,18 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||||||
<Show when={showCreateSession()}>
|
<Show when={showCreateSession()}>
|
||||||
<CreateSessionDialog onClose={() => setShowCreateSession(false)} />
|
<CreateSessionDialog onClose={() => setShowCreateSession(false)} />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
{/* Delete session modal */}
|
||||||
|
<Show when={sessionToDelete()}>
|
||||||
|
{(target) => (
|
||||||
|
<DeleteSessionDialog
|
||||||
|
sessionId={target().id}
|
||||||
|
sessionName={target().name}
|
||||||
|
active={stream.sessionId === target().id}
|
||||||
|
onClose={() => setSessionToDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</JournalContext.Provider>
|
</JournalContext.Provider>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,15 +2,20 @@
|
|||||||
* SessionDropdown — session switcher for the journal header (GM only)
|
* SessionDropdown — session switcher for the journal header (GM only)
|
||||||
*/
|
*/
|
||||||
import { Component, createSignal, For, Show } from "solid-js";
|
import { Component, createSignal, For, Show } from "solid-js";
|
||||||
|
import {
|
||||||
|
TbOutlineCheck,
|
||||||
|
TbOutlineChevronDown,
|
||||||
|
TbOutlineTrash,
|
||||||
|
} from "solid-icons/tb";
|
||||||
import {
|
import {
|
||||||
sessions,
|
sessions,
|
||||||
setSessionId,
|
joinSession,
|
||||||
hydrateFromServer,
|
|
||||||
useJournalStream,
|
useJournalStream,
|
||||||
} from "../stores/journalStream";
|
} from "../stores/journalStream";
|
||||||
|
|
||||||
interface SessionDropdownProps {
|
interface SessionDropdownProps {
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
|
onDelete: (id: string, name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
|
export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
|
||||||
@@ -25,8 +30,9 @@ export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
setSessionId(id);
|
joinSession(id).catch((e) => {
|
||||||
hydrateFromServer(id).catch(() => {});
|
console.error("[journal] failed to switch session:", e);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close dropdown on outside click
|
// Close dropdown on outside click
|
||||||
@@ -45,7 +51,7 @@ export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
|
|||||||
class="text-[10px] text-gray-400 hover:text-gray-600 truncate leading-tight cursor-pointer text-left"
|
class="text-[10px] text-gray-400 hover:text-gray-600 truncate leading-tight cursor-pointer text-left"
|
||||||
>
|
>
|
||||||
{stream.sessionName || stream.sessionId}{" "}
|
{stream.sessionName || stream.sessionId}{" "}
|
||||||
<span class="text-gray-300">▾</span>
|
<TbOutlineChevronDown class="text-gray-300 inline-block align-[-0.125em]" />
|
||||||
</button>
|
</button>
|
||||||
<Show when={dropdownOpen()}>
|
<Show when={dropdownOpen()}>
|
||||||
<div
|
<div
|
||||||
@@ -54,22 +60,37 @@ export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<For each={Object.entries(manifest().sessions)}>
|
<For each={Object.entries(manifest().sessions)}>
|
||||||
{([id, meta]) => (
|
{([id, meta]) => (
|
||||||
|
<div
|
||||||
|
class={`flex items-center ${
|
||||||
|
id === stream.sessionId ? "bg-blue-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleSessionSelect(id);
|
handleSessionSelect(id);
|
||||||
}}
|
}}
|
||||||
class={`w-full text-left px-3 py-1 text-xs hover:bg-gray-100 flex items-center gap-1 ${
|
class={`flex-1 min-w-0 text-left px-3 py-1 text-xs hover:bg-gray-100 flex items-center gap-1 ${
|
||||||
id === stream.sessionId
|
id === stream.sessionId ? "text-blue-700" : "text-gray-700"
|
||||||
? "bg-blue-50 text-blue-700"
|
|
||||||
: "text-gray-700"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span class="truncate flex-1">{meta.name || id}</span>
|
<span class="truncate flex-1">{meta.name || id}</span>
|
||||||
<Show when={id === stream.sessionId}>
|
<Show when={id === stream.sessionId}>
|
||||||
<span class="text-blue-500 shrink-0">✓</span>
|
<TbOutlineCheck class="text-blue-500 shrink-0" />
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDropdownOpen(false);
|
||||||
|
props.onDelete(id, meta.name || id);
|
||||||
|
}}
|
||||||
|
class="shrink-0 px-2 py-1 text-[10px] text-gray-300 hover:text-red-500"
|
||||||
|
title="删除会话"
|
||||||
|
>
|
||||||
|
<TbOutlineTrash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<div class="border-t border-gray-100 my-0.5" />
|
<div class="border-t border-gray-100 my-0.5" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, Show, createMemo } from "solid-js";
|
import { Component, Show, createMemo } from "solid-js";
|
||||||
|
import { TbOutlineArrowBackUp } from "solid-icons/tb";
|
||||||
import type { StreamMessage as StreamMessageType } from "./registry";
|
import type { StreamMessage as StreamMessageType } from "./registry";
|
||||||
import { getMessageType } from "./registry";
|
import { getMessageType } from "./registry";
|
||||||
import { revertLatest, useJournalStream } from "../stores/journalStream";
|
import { revertLatest, useJournalStream } from "../stores/journalStream";
|
||||||
@@ -47,7 +48,7 @@ export const StreamMessageCard: Component<{
|
|||||||
class="text-xs text-gray-300 hover:text-red-500 ml-auto px-0.5 leading-none"
|
class="text-xs text-gray-300 hover:text-red-500 ml-auto px-0.5 leading-none"
|
||||||
title="撤回此消息"
|
title="撤回此消息"
|
||||||
>
|
>
|
||||||
×
|
<TbOutlineArrowBackUp />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export const VariableView: Component = () => {
|
|||||||
|
|
||||||
const values = createMemo(() => stream.variables);
|
const values = createMemo(() => stream.variables);
|
||||||
|
|
||||||
|
/** Runtime engine diagnostics (bad declarations / modifiers). */
|
||||||
|
const diagnostics = createMemo(() => comp.diagnostics());
|
||||||
|
|
||||||
/** Flat list of all variables */
|
/** Flat list of all variables */
|
||||||
const items = createMemo(() => {
|
const items = createMemo(() => {
|
||||||
const vars = values();
|
const vars = values();
|
||||||
@@ -32,11 +35,25 @@ export const VariableView: Component = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="h-full overflow-y-auto bg-gray-50">
|
<div class="h-full overflow-y-auto bg-gray-50">
|
||||||
|
<Show when={diagnostics().length > 0}>
|
||||||
|
<div class="border-b border-amber-200 bg-amber-50 px-3 py-2">
|
||||||
|
<div class="text-xs font-semibold text-amber-800 mb-1">
|
||||||
|
变量求值有 {diagnostics().length} 个问题
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<For each={diagnostics()}>
|
||||||
|
{(d) => (
|
||||||
|
<li class="text-xs text-amber-700 font-mono break-all">{d.message}</li>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={hasAnyData()}
|
when={hasAnyData()}
|
||||||
fallback={
|
fallback={
|
||||||
<p class="text-center text-gray-400 text-xs py-8">
|
<p class="text-center text-gray-400 text-xs py-8">
|
||||||
暂无变量定义。在文档中使用 ```csv role=declare 代码块定义变量。
|
暂无变量定义。在文档中使用 ```yaml role=declare 代码块定义变量。
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import type { CompletionItem } from "./command-parser";
|
import type { CompletionItem } from "./command-parser";
|
||||||
import type { JournalCompletions } from "./completions";
|
import type { JournalCompletions } from "./completions";
|
||||||
|
import { TbOutlineDice5, TbOutlineSparkles } from "solid-icons/tb";
|
||||||
|
|
||||||
export interface CompletionsContext {
|
export interface CompletionsContext {
|
||||||
data: JournalCompletions;
|
data: JournalCompletions;
|
||||||
@@ -63,7 +64,8 @@ export function buildCompletions(
|
|||||||
|
|
||||||
for (const d of diceMatches) {
|
for (const d of diceMatches) {
|
||||||
items.push({
|
items.push({
|
||||||
label: `🎲 ${d.notation}`,
|
label: d.notation,
|
||||||
|
icon: TbOutlineDice5,
|
||||||
kind: "value",
|
kind: "value",
|
||||||
insertText: "/roll " + d.notation,
|
insertText: "/roll " + d.notation,
|
||||||
});
|
});
|
||||||
@@ -71,7 +73,8 @@ export function buildCompletions(
|
|||||||
|
|
||||||
for (const s of sparkMatches) {
|
for (const s of sparkMatches) {
|
||||||
items.push({
|
items.push({
|
||||||
label: `✨ ${s.slug} (${s.notation})`,
|
label: `${s.slug} (${s.notation})`,
|
||||||
|
icon: TbOutlineSparkles,
|
||||||
kind: "value",
|
kind: "value",
|
||||||
insertText: `/roll ${s.slug}`,
|
insertText: `/roll ${s.slug}`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { parseInput } from "./command-parser";
|
|||||||
import { resolveRollPayload } from "./types/roll";
|
import { resolveRollPayload } from "./types/roll";
|
||||||
import { resolveSparkPayload } from "./types/spark";
|
import { resolveSparkPayload } from "./types/spark";
|
||||||
import { evaluateExpression, exprValueToString } from "./variable-expression";
|
import { evaluateExpression, exprValueToString } from "./variable-expression";
|
||||||
import { computeCascade, getCombined, setBase } from "./var-reactivity";
|
import { computeCascade, getCombined, resolveVariable, setBase } from "./var-reactivity";
|
||||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
import { ensureReactivity } from "./completions";
|
||||||
|
|
||||||
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
|
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
|
||||||
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
@@ -58,10 +58,6 @@ export interface DispatchContext {
|
|||||||
}[];
|
}[];
|
||||||
/** Current runtime variable values */
|
/** Current runtime variable values */
|
||||||
variables: Record<string, string>;
|
variables: Record<string, string>;
|
||||||
/** Variable declarations (from role=declare blocks) */
|
|
||||||
declarations: VarDeclaration[];
|
|
||||||
/** Tag modifiers (from role=declare blocks) */
|
|
||||||
tagModifiers: TagModifier[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,6 +70,10 @@ export async function dispatchCommand(
|
|||||||
const raw = ctx.command.trim();
|
const raw = ctx.command.trim();
|
||||||
if (!raw) return { ok: false, error: "Empty command" };
|
if (!raw) return { ok: false, error: "Empty command" };
|
||||||
|
|
||||||
|
// Make sure the engine reflects the currently loaded declarations; this
|
||||||
|
// re-initializes only when they changed since the last dispatch.
|
||||||
|
ensureReactivity();
|
||||||
|
|
||||||
// Observers can only send chat
|
// Observers can only send chat
|
||||||
if (ctx.role === "observer") {
|
if (ctx.role === "observer") {
|
||||||
const result = sendMessage("chat", { text: raw });
|
const result = sendMessage("chat", { text: raw });
|
||||||
@@ -131,7 +131,8 @@ export async function dispatchCommand(
|
|||||||
if (arg.includes("$")) {
|
if (arg.includes("$")) {
|
||||||
try {
|
try {
|
||||||
const ev = evaluateExpression(arg, {
|
const ev = evaluateExpression(arg, {
|
||||||
lookup: (name: string) => getCombined("$" + name, ctx.variables),
|
// Strict: referencing an undefined variable is an error, not 0.
|
||||||
|
lookup: (name) => resolveVariable(name, ctx.variables),
|
||||||
});
|
});
|
||||||
if (ev.value.kind !== "number") {
|
if (ev.value.kind !== "number") {
|
||||||
return finish({ ok: false, error: "Roll expression must evaluate to a number" });
|
return finish({ ok: false, error: "Roll expression must evaluate to a number" });
|
||||||
@@ -204,11 +205,8 @@ function dispatchSet(
|
|||||||
} else if (p.expr) {
|
} else if (p.expr) {
|
||||||
// Evaluate expression — handles both numeric and tagmap values
|
// Evaluate expression — handles both numeric and tagmap values
|
||||||
const result = evaluateExpression(p.expr, {
|
const result = evaluateExpression(p.expr, {
|
||||||
lookup: (name: string) => {
|
// Strict: referencing an undefined variable is an error, not 0.
|
||||||
const k = "$" + name;
|
lookup: (name) => resolveVariable(name, ctx.variables),
|
||||||
// Use getCombined with stream fallback for accurate values
|
|
||||||
return k === key ? undefined : getCombined(k, ctx.variables);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
newValue = exprValueToString(result.value);
|
newValue = exprValueToString(result.value);
|
||||||
} else {
|
} else {
|
||||||
@@ -236,6 +234,7 @@ function dispatchSet(
|
|||||||
action: "set",
|
action: "set",
|
||||||
key: change.key,
|
key: change.key,
|
||||||
value: change.value,
|
value: change.value,
|
||||||
|
base: change.base,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -244,10 +243,15 @@ function dispatchSet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send the direct set last so cascade messages arrive first.
|
// Send the direct set last so cascade messages arrive first.
|
||||||
// Use getCombined with stream fallback so the stream store receives the
|
// Publish the combined value for display plus the raw base so hydrated
|
||||||
// correct value (base + active mods) rather than just the raw base.
|
// clients can rebuild the engine exactly (see rebuildReactivityFromStore).
|
||||||
const combinedValue = getCombined(key, ctx.variables);
|
const combinedValue = getCombined(key, ctx.variables);
|
||||||
const r1 = sendMessage("var", { action: "set", key, value: combinedValue });
|
const r1 = sendMessage("var", {
|
||||||
|
action: "set",
|
||||||
|
key,
|
||||||
|
value: combinedValue,
|
||||||
|
base: newValue,
|
||||||
|
});
|
||||||
const u1 = unwrap(r1);
|
const u1 = unwrap(r1);
|
||||||
if (!u1.ok) return u1;
|
if (!u1.ok) return u1;
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,14 @@
|
|||||||
* module and keep JournalInput small.
|
* module and keep JournalInput small.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { IconTypes } from "solid-icons";
|
||||||
|
|
||||||
export interface CompletionItem {
|
export interface CompletionItem {
|
||||||
label: string;
|
label: string;
|
||||||
kind: "command" | "value" | "no-results";
|
kind: "command" | "value" | "no-results";
|
||||||
insertText: string;
|
insertText: string;
|
||||||
|
/** Optional icon rendered before the label in the completions dropdown. */
|
||||||
|
icon?: IconTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedInput {
|
export interface ParsedInput {
|
||||||
|
|||||||
@@ -30,8 +30,13 @@ import type {
|
|||||||
VarDeclaration,
|
VarDeclaration,
|
||||||
TagModifier,
|
TagModifier,
|
||||||
} from "../../cli/completions/types";
|
} from "../../cli/completions/types";
|
||||||
import { initReactivity, computeInitialValues } from "./var-reactivity";
|
import { initReactivity, computeInitialValues, rebuildReactivityFromStore, getReactivityDiagnostics, onReactivityDiagnostics, type ReactivityDiagnostic } from "./var-reactivity";
|
||||||
import { sendMessage, journalStreamState } from "../stores/journalStream";
|
import {
|
||||||
|
sendMessage,
|
||||||
|
journalStreamState,
|
||||||
|
hasHydrated,
|
||||||
|
setHydrationListener,
|
||||||
|
} from "../stores/journalStream";
|
||||||
|
|
||||||
export type { VarDeclaration, TagModifier };
|
export type { VarDeclaration, TagModifier };
|
||||||
|
|
||||||
@@ -53,6 +58,14 @@ const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
|
|||||||
status: "loading",
|
status: "loading",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Runtime diagnostics from the variable engine (bad declarations, modifier
|
||||||
|
// expressions, circular deps). Bridged into a signal so components can render
|
||||||
|
// them; the engine itself stays Solid-free.
|
||||||
|
const [reactivityDiagnostics, setReactivityDiagnostics] = createSignal<
|
||||||
|
ReactivityDiagnostic[]
|
||||||
|
>(getReactivityDiagnostics());
|
||||||
|
onReactivityDiagnostics((list) => setReactivityDiagnostics(list));
|
||||||
|
|
||||||
// The registry backing the completions. Populated in both CLI and client
|
// The registry backing the completions. Populated in both CLI and client
|
||||||
// modes so inline content ids can be resolved at runtime (e.g. spark rolls).
|
// modes so inline content ids can be resolved at runtime (e.g. spark rolls).
|
||||||
let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||||
@@ -133,22 +146,84 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
|||||||
|
|
||||||
// ------------------- Init (runs eagerly at import time) -------------------
|
// ------------------- Init (runs eagerly at import time) -------------------
|
||||||
|
|
||||||
|
// Declarations finish loading asynchronously, as does worker hydration. Either
|
||||||
|
// may land first, so seeding is driven by whichever becomes ready last.
|
||||||
|
let _reactivityReady = false;
|
||||||
|
|
||||||
|
/** Signature of the declarations/tag modifiers last applied to the engine. */
|
||||||
|
let _reactivitySignature: string | null = null;
|
||||||
|
|
||||||
|
function reactivitySignature(
|
||||||
|
declarations: VarDeclaration[],
|
||||||
|
tagModifiers: TagModifier[],
|
||||||
|
): string {
|
||||||
|
return JSON.stringify({ declarations, tagModifiers });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the reactivity engine from the given declarations, then (if the
|
||||||
|
* store is hydrated) rebuild it from the store and seed missing values.
|
||||||
|
* Rebuilding uses both the combined values and their exact bases so tag
|
||||||
|
* modifiers are not double-counted.
|
||||||
|
*/
|
||||||
|
function applyReactivity(
|
||||||
|
declarations: VarDeclaration[],
|
||||||
|
tagModifiers: TagModifier[],
|
||||||
|
): void {
|
||||||
|
initReactivity({ declarations, tagModifiers });
|
||||||
|
_reactivityReady = true;
|
||||||
|
_reactivitySignature = reactivitySignature(declarations, tagModifiers);
|
||||||
|
|
||||||
|
if (hasHydrated()) {
|
||||||
|
rebuildReactivityFromStore(
|
||||||
|
journalStreamState.variables,
|
||||||
|
journalStreamState.variableBases,
|
||||||
|
);
|
||||||
|
seedDeclaredVariables();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the engine matches the currently loaded completions, re-initializing
|
||||||
|
* only when the declaration set has changed since the last apply. No-op while
|
||||||
|
* completions are still loading. Called at bootstrap and before each dispatch,
|
||||||
|
* so a content rescan takes effect without a full reload.
|
||||||
|
*/
|
||||||
|
export function ensureReactivity(): void {
|
||||||
|
const s = completionsState();
|
||||||
|
if (s.status !== "loaded") return;
|
||||||
|
|
||||||
|
const signature = reactivitySignature(s.data.declarations, s.data.tagModifiers);
|
||||||
|
if (_reactivityReady && _reactivitySignature === signature) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
applyReactivity(s.data.declarations, s.data.tagModifiers);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[completions] reactivity init error:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seeding runs after every hydration snapshot so declared variables are
|
||||||
|
// computed against the real session store rather than an empty one.
|
||||||
|
setHydrationListener(() => {
|
||||||
|
try {
|
||||||
|
seedDeclaredVariables();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[completions] seed error:", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Using a top-level IIFE so the promise starts immediately
|
// Using a top-level IIFE so the promise starts immediately
|
||||||
const _initPromise: Promise<void> = (async () => {
|
const _initPromise: Promise<void> = (async () => {
|
||||||
// 1. Try server first (CLI mode)
|
// 1. Try server first (CLI mode)
|
||||||
const serverData = await tryServer();
|
const serverData = await tryServer();
|
||||||
if (serverData) {
|
if (serverData) {
|
||||||
setCompletionsState({ status: "loaded", data: serverData });
|
// Load the registry before publishing the loaded state: dispatching a
|
||||||
|
// command only becomes possible once completions are "loaded", and inline
|
||||||
|
// spark-table CSVs are resolved through this registry at roll time.
|
||||||
await tryServerRegistry();
|
await tryServerRegistry();
|
||||||
try {
|
setCompletionsState({ status: "loaded", data: serverData });
|
||||||
initReactivity({
|
ensureReactivity();
|
||||||
declarations: serverData.declarations,
|
|
||||||
tagModifiers: serverData.tagModifiers,
|
|
||||||
});
|
|
||||||
seedDeclaredVariables();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[completions] reactivity init error:", e);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,15 +232,7 @@ const _initPromise: Promise<void> = (async () => {
|
|||||||
const data = await scanClientSide();
|
const data = await scanClientSide();
|
||||||
if (data.dice.length > 0 || data.links.length > 0) {
|
if (data.dice.length > 0 || data.links.length > 0) {
|
||||||
setCompletionsState({ status: "loaded", data });
|
setCompletionsState({ status: "loaded", data });
|
||||||
try {
|
ensureReactivity();
|
||||||
initReactivity({
|
|
||||||
declarations: data.declarations,
|
|
||||||
tagModifiers: data.tagModifiers,
|
|
||||||
});
|
|
||||||
seedDeclaredVariables();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[completions] reactivity init error:", e);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setCompletionsState({ status: "empty" });
|
setCompletionsState({ status: "empty" });
|
||||||
}
|
}
|
||||||
@@ -194,13 +261,16 @@ export function ensureCompletions(): Promise<void> {
|
|||||||
export function useJournalCompletions(): {
|
export function useJournalCompletions(): {
|
||||||
state: CompletionsState;
|
state: CompletionsState;
|
||||||
data: JournalCompletions;
|
data: JournalCompletions;
|
||||||
|
/** Reactive accessor for runtime variable-engine diagnostics. */
|
||||||
|
diagnostics: () => ReactivityDiagnostic[];
|
||||||
} {
|
} {
|
||||||
const s = completionsState();
|
const s = completionsState();
|
||||||
if (s.status === "loaded") {
|
if (s.status === "loaded") {
|
||||||
return { state: s, data: s.data };
|
return { state: s, data: s.data, diagnostics: reactivityDiagnostics };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
state: s,
|
state: s,
|
||||||
|
diagnostics: reactivityDiagnostics,
|
||||||
data: {
|
data: {
|
||||||
dice: [],
|
dice: [],
|
||||||
links: [],
|
links: [],
|
||||||
@@ -216,8 +286,15 @@ export function useJournalCompletions(): {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function seedDeclaredVariables(): void {
|
function seedDeclaredVariables(): void {
|
||||||
const initial = computeInitialValues(journalStreamState.variables);
|
if (!_reactivityReady) return;
|
||||||
for (const { key, value } of initial) {
|
// Only seed into a live session. Before connect the store is empty and
|
||||||
sendMessage("var", { action: "set", key, value });
|
// publishing derived defaults would clobber the session once it joins.
|
||||||
|
if (!journalStreamState.connected) return;
|
||||||
|
|
||||||
|
const store = journalStreamState.variables;
|
||||||
|
const existing = new Set(Object.keys(store));
|
||||||
|
const initial = computeInitialValues(store, existing);
|
||||||
|
for (const { key, value, base } of initial) {
|
||||||
|
sendMessage("var", { action: "set", key, value, base });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
parseDeclareCsv,
|
parseDeclare,
|
||||||
|
declareSchema,
|
||||||
type VarDeclaration,
|
type VarDeclaration,
|
||||||
type TagModifier,
|
type TagModifier,
|
||||||
type DeclareResult,
|
type DeclareResult,
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ export {
|
|||||||
} from "./registry";
|
} from "./registry";
|
||||||
export type { MessageTypeDef, StreamMessage } from "./registry";
|
export type { MessageTypeDef, StreamMessage } from "./registry";
|
||||||
export {
|
export {
|
||||||
connectStream,
|
connectBroker,
|
||||||
|
joinSession,
|
||||||
|
switchSession,
|
||||||
|
leaveSession,
|
||||||
disconnectStream,
|
disconnectStream,
|
||||||
hydrateFromServer,
|
resolveBrokerUrl,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
revertLatest,
|
revertLatest,
|
||||||
canRevert,
|
canRevert,
|
||||||
@@ -42,13 +45,13 @@ export { ConnectDialog } from "./ConnectDialog";
|
|||||||
export { InviteDialog } from "./InviteDialog";
|
export { InviteDialog } from "./InviteDialog";
|
||||||
export { SessionDropdown } from "./SessionDropdown";
|
export { SessionDropdown } from "./SessionDropdown";
|
||||||
export { CreateSessionDialog } from "./CreateSessionDialog";
|
export { CreateSessionDialog } from "./CreateSessionDialog";
|
||||||
export { ConnectionBar } from "./ConnectionBar";
|
export { DeleteSessionDialog } from "./DeleteSessionDialog";
|
||||||
export { StreamView } from "./StreamView";
|
export { StreamView } from "./StreamView";
|
||||||
export { StreamMessageCard } from "./StreamMessage";
|
export { StreamMessageCard } from "./StreamMessage";
|
||||||
export { ComposePanel } from "./ComposePanel";
|
export { ComposePanel } from "./ComposePanel";
|
||||||
export { JournalInput } from "./JournalInput";
|
export { JournalInput } from "./JournalInput";
|
||||||
export { DynamicForm } from "./DynamicForm";
|
export { DynamicForm } from "./DynamicForm";
|
||||||
export { useJournalCompletions } from "./completions";
|
export { useJournalCompletions, ensureReactivity } from "./completions";
|
||||||
export { dispatchCommand } from "./command-dispatcher";
|
export { dispatchCommand } from "./command-dispatcher";
|
||||||
export type { DispatchContext, DispatchResult } from "./command-dispatcher";
|
export type { DispatchContext, DispatchResult } from "./command-dispatcher";
|
||||||
export { parseInput } from "./command-parser";
|
export { parseInput } from "./command-parser";
|
||||||
@@ -56,11 +59,11 @@ export type { ParsedInput, CompletionItem } from "./command-parser";
|
|||||||
export { buildCompletions } from "./command-completions";
|
export { buildCompletions } from "./command-completions";
|
||||||
export type { CompletionsContext } from "./command-completions";
|
export type { CompletionsContext } from "./command-completions";
|
||||||
export { VariableView } from "./VariableView";
|
export { VariableView } from "./VariableView";
|
||||||
export { parseDeclareCsv } from "./declare-parser";
|
export { parseDeclare, declareSchema } from "./declare-parser";
|
||||||
export type { VarDeclaration, TagModifier } from "./declare-parser";
|
export type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||||
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
|
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
|
||||||
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
|
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
|
||||||
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
|
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues, getReactivityDiagnostics, clearReactivityDiagnostics, onReactivityDiagnostics, resolveVariable } from "./var-reactivity";
|
||||||
export type { VarReactivityState, VariableStore } from "./var-reactivity";
|
export type { VarReactivityState, VariableStore, ReactivityDiagnostic } from "./var-reactivity";
|
||||||
export { JournalContext, useJournalContext } from "./JournalContext";
|
export { JournalContext, useJournalContext } from "./JournalContext";
|
||||||
export type { JournalContextValue } from "./JournalContext";
|
export type { JournalContextValue } from "./JournalContext";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { TbOutlineCircleCheck, TbOutlineHandStop } from "solid-icons/tb";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -25,7 +26,7 @@ registerMessageType<IntentPayload>({
|
|||||||
render: (p) => (
|
render: (p) => (
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-lg">🙋</span>
|
<TbOutlineHandStop class="text-lg" />
|
||||||
<span class="text-sm font-medium text-yellow-700">意图</span>
|
<span class="text-sm font-medium text-yellow-700">意图</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm">{p.description}</p>
|
<p class="text-sm">{p.description}</p>
|
||||||
@@ -58,7 +59,7 @@ registerMessageType<ResolutionPayload>({
|
|||||||
render: (p) => (
|
render: (p) => (
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-lg">✅</span>
|
<TbOutlineCircleCheck class="text-lg" />
|
||||||
<span class="text-sm font-medium text-green-700">裁决</span>
|
<span class="text-sm font-medium text-green-700">裁决</span>
|
||||||
<span class="text-xs text-gray-400">re: {p.intentId}</span>
|
<span class="text-xs text-gray-400">re: {p.intentId}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component } from "solid-js";
|
import { Component } from "solid-js";
|
||||||
|
import { TbOutlineFileText } from "solid-icons/tb";
|
||||||
import { useNavigateWithParams } from "../../useNavigateWithParams";
|
import { useNavigateWithParams } from "../../useNavigateWithParams";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
@@ -69,7 +70,7 @@ const RevealLink: Component<LinkPayload> = (p) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-lg">📄</span>
|
<TbOutlineFileText class="text-lg" />
|
||||||
<div>
|
<div>
|
||||||
<a
|
<a
|
||||||
href={target()}
|
href={target()}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { TbOutlineArrowRight, TbOutlineDice5 } from "solid-icons/tb";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
import { rollFormula } from "../../md-commander/hooks";
|
import { rollFormula } from "../../dice";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
notation: z.string().min(1),
|
notation: z.string().min(1),
|
||||||
@@ -55,9 +56,10 @@ registerMessageType<RollPayload>({
|
|||||||
return (
|
return (
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="text-lg">🎲</span>
|
<TbOutlineDice5 class="text-lg" />
|
||||||
<span class="font-mono text-sm">
|
<span class="font-mono text-sm">
|
||||||
{p.notation} →{" "}
|
{p.notation}{" "}
|
||||||
|
<TbOutlineArrowRight class="inline-block align-[-0.125em]" />{" "}
|
||||||
<span class="font-bold text-base">{result.total}</span>
|
<span class="font-bold text-base">{result.total}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { For } from "solid-js";
|
import { For } from "solid-js";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
import { rollFormula } from "../../md-commander/hooks";
|
|
||||||
import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table";
|
import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table";
|
||||||
import { getIndexedData } from "../../../data-loader/file-index";
|
import { getIndexedData } from "../../../data-loader/file-index";
|
||||||
import { getRegistry } from "../../journal/completions";
|
import { getRegistry } from "../../journal/completions";
|
||||||
|
import { parseMarkdown } from "../../../markdown";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Schema
|
// Schema
|
||||||
@@ -28,23 +28,20 @@ const sparkColumnSchema = z.object({
|
|||||||
header: z.string(),
|
header: z.string(),
|
||||||
slug: z.string(),
|
slug: z.string(),
|
||||||
value: z.string(),
|
value: z.string(),
|
||||||
|
/** Dice total that selected this column's row. */
|
||||||
|
roll: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const sparkResultSchema = z.object({
|
const sparkResultSchema = z.object({
|
||||||
notation: z.string(),
|
notation: z.string(),
|
||||||
columns: z.array(sparkColumnSchema),
|
columns: z.array(sparkColumnSchema),
|
||||||
source: z.string(),
|
source: z.string(),
|
||||||
|
/** True when each column rolled independently. */
|
||||||
|
remix: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const schema = z.object({
|
/** Shape of a single dice-engine roll result. */
|
||||||
/** User-visible label, e.g. "adventure body-label" */
|
const rollDetailSchema = z.object({
|
||||||
label: z.string(),
|
|
||||||
/** The dice notation from the spark table header */
|
|
||||||
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(),
|
total: z.number(),
|
||||||
detail: z.string(),
|
detail: z.string(),
|
||||||
plainDetail: z.string(),
|
plainDetail: z.string(),
|
||||||
@@ -54,7 +51,20 @@ const schema = z.object({
|
|||||||
subtotal: z.number(),
|
subtotal: z.number(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
/** User-visible label, e.g. "adventure body-label" */
|
||||||
|
label: z.string(),
|
||||||
|
/** The dice notation from the spark table header */
|
||||||
|
notation: z.string().min(1),
|
||||||
|
/** Resolved spark table columns */
|
||||||
|
sparkTable: sparkResultSchema,
|
||||||
|
/**
|
||||||
|
* 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>;
|
export type SparkPayload = z.infer<typeof schema>;
|
||||||
@@ -105,13 +115,13 @@ export async function resolveSparkPayload(raw: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sparkResult = rollSparkTable(meta, { remix: raw.remix });
|
const sparkResult = rollSparkTable(meta, { remix: raw.remix });
|
||||||
const firstRoll = rollFormula(meta.notation);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: raw.key,
|
label: raw.key,
|
||||||
notation: meta.notation,
|
notation: meta.notation,
|
||||||
sparkTable: sparkResult,
|
// Source path lets markdown in the rolled values resolve relative refs.
|
||||||
rollResult: firstRoll.result,
|
sparkTable: { ...sparkResult, source: raw.docPath ?? "" },
|
||||||
|
rollResult: sparkResult.roll,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,35 +137,47 @@ registerMessageType<SparkPayload>({
|
|||||||
defaultPayload: () => ({
|
defaultPayload: () => ({
|
||||||
label: "",
|
label: "",
|
||||||
notation: "d6",
|
notation: "d6",
|
||||||
sparkTable: { notation: "d6", columns: [], source: "" },
|
sparkTable: { notation: "d6", columns: [], source: "", remix: false },
|
||||||
rollResult: { total: 0, detail: "", plainDetail: "", pools: [] },
|
|
||||||
}),
|
}),
|
||||||
render: (p) => {
|
render: (p) => {
|
||||||
const st = p.sparkTable;
|
const st = p.sparkTable;
|
||||||
|
const sharedRoll = p.rollResult?.total;
|
||||||
return (
|
return (
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<span class="text-lg">✨</span>
|
|
||||||
<span class="font-mono text-xs text-purple-600">
|
|
||||||
{st.notation} · spark
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="bg-purple-50 rounded border border-purple-200 overflow-hidden">
|
<div class="bg-purple-50 rounded border border-purple-200 overflow-hidden">
|
||||||
<table class="w-full text-xs">
|
<table class="w-full text-xs">
|
||||||
<tbody>
|
<tbody>
|
||||||
<For each={st.columns}>
|
<For each={st.columns}>
|
||||||
{(col) => (
|
{(col) => {
|
||||||
|
const roll = col.roll ?? sharedRoll;
|
||||||
|
return (
|
||||||
<tr class="border-t border-purple-100 first:border-t-0">
|
<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">
|
<td class="px-2 py-1 text-purple-600 font-medium whitespace-nowrap">
|
||||||
{col.header}
|
{col.header}
|
||||||
</td>
|
{roll != null && (
|
||||||
<td class="px-2 py-1 text-gray-800">{col.value}</td>
|
<span class="font-mono text-purple-400">
|
||||||
</tr>
|
{" / "}
|
||||||
|
{st.notation}
|
||||||
|
{" -> "}
|
||||||
|
{roll}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<For each={st.columns}>
|
||||||
|
{(col) => (
|
||||||
|
<div
|
||||||
|
class="px-2 py-1 [&_p]:m-0"
|
||||||
|
innerHTML={parseMarkdown(col.value, st.source)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,20 +4,25 @@
|
|||||||
* Emitters: gm, player
|
* Emitters: gm, player
|
||||||
* Command: /set $key expression
|
* Command: /set $key expression
|
||||||
*
|
*
|
||||||
* Variable declarations and tag modifiers are authored in ```csv role=declare
|
* Variable declarations and tag modifiers are authored in ```yaml role=declare
|
||||||
* code blocks in markdown documents. The stream carries set/del mutations
|
* code blocks in markdown documents. The stream carries `set` mutations that
|
||||||
* that build up the runtime variable store additively.
|
* build up the runtime variable store additively.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { TbOutlineHash } from "solid-icons/tb";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
import { journalSetState } from "../../stores/journalStream";
|
import { journalSetState } from "../../stores/journalStream";
|
||||||
import { produce } from "solid-js/store";
|
import { produce } from "solid-js/store";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
action: z.enum(["set", "del"]),
|
action: z.literal("set"),
|
||||||
key: z.string().min(1),
|
key: z.string().min(1),
|
||||||
value: z.string().optional(),
|
value: z.string().optional(),
|
||||||
|
/** The raw base value (before tag modifiers). The store's `variables` map
|
||||||
|
* holds combined values, so this is what the reactivity engine rebuilds
|
||||||
|
* from on hydration. */
|
||||||
|
base: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type VarPayload = z.infer<typeof schema>;
|
export type VarPayload = z.infer<typeof schema>;
|
||||||
@@ -29,19 +34,9 @@ registerMessageType<VarPayload>({
|
|||||||
schema,
|
schema,
|
||||||
defaultPayload: () => ({ action: "set", key: "", value: "" }),
|
defaultPayload: () => ({ action: "set", key: "", value: "" }),
|
||||||
render: (p) => {
|
render: (p) => {
|
||||||
if (p.action === "del") {
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="text-lg">🔢</span>
|
<TbOutlineHash class="text-lg" />
|
||||||
<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>
|
|
||||||
<span class="font-mono text-sm">
|
<span class="font-mono text-sm">
|
||||||
{p.key} = <span class="font-bold">{p.value}</span>
|
{p.key} = <span class="font-bold">{p.value}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -51,10 +46,13 @@ registerMessageType<VarPayload>({
|
|||||||
reducer: (p) => {
|
reducer: (p) => {
|
||||||
journalSetState(
|
journalSetState(
|
||||||
produce((s) => {
|
produce((s) => {
|
||||||
if (p.action === "del") {
|
if (p.value !== undefined) {
|
||||||
delete s.variables[p.key];
|
|
||||||
} else if (p.value !== undefined) {
|
|
||||||
s.variables[p.key] = p.value;
|
s.variables[p.key] = p.value;
|
||||||
|
if (p.base !== undefined) {
|
||||||
|
s.variableBases[p.key] = p.base;
|
||||||
|
} else {
|
||||||
|
delete s.variableBases[p.key];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ export interface VarReactivityState {
|
|||||||
|
|
||||||
export type VariableStore = Record<string, string>;
|
export type VariableStore = Record<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A variable update produced by the engine: the combined `value` to publish
|
||||||
|
* for display, plus the raw `base` it was derived from. The base is carried on
|
||||||
|
* the stream so a hydrated client can rebuild the engine without having to
|
||||||
|
* reverse-engineer base values out of combined ones.
|
||||||
|
*/
|
||||||
|
export interface VarChange {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
base: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** A single modifier applied to a target variable. */
|
/** A single modifier applied to a target variable. */
|
||||||
export interface ActiveMod {
|
export interface ActiveMod {
|
||||||
tag: string; // "#warrior"
|
tag: string; // "#warrior"
|
||||||
@@ -44,6 +56,26 @@ export interface ActiveMod {
|
|||||||
kind: "numeric" | "tagmap";
|
kind: "numeric" | "tagmap";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A problem detected while evaluating declarations or tag modifiers at
|
||||||
|
* runtime. These are benign to the engine (the offending entry is skipped) but
|
||||||
|
* signal an authoring mistake that would otherwise silently produce a wrong
|
||||||
|
* value, so they are collected for surfacing in the UI.
|
||||||
|
*/
|
||||||
|
export interface ReactivityDiagnostic {
|
||||||
|
kind: "init" | "declaration" | "modifier";
|
||||||
|
/** Human-readable description, already including the offending context. */
|
||||||
|
message: string;
|
||||||
|
/** The variable the diagnostic concerns (e.g. "$hp"). */
|
||||||
|
key?: string;
|
||||||
|
/** The expression that failed to evaluate. */
|
||||||
|
expression?: string;
|
||||||
|
/** For modifiers: the tag whose activation triggered the modifier ("#warrior"). */
|
||||||
|
tag?: string;
|
||||||
|
/** For modifiers: the variable whose tagmap activated the tag ("$class"). */
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tagmap helpers
|
// Tagmap helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -121,6 +153,61 @@ const sourceActivations = new Map<string, Array<{
|
|||||||
/** Set of $vars currently being re-evaluated (cycle guard) */
|
/** Set of $vars currently being re-evaluated (cycle guard) */
|
||||||
const inFlight = new Set<string>();
|
const inFlight = new Set<string>();
|
||||||
|
|
||||||
|
/** Diagnostics collected since the last init, keyed by a stable id to dedupe. */
|
||||||
|
const diagnostics = new Map<string, ReactivityDiagnostic>();
|
||||||
|
|
||||||
|
/** Listeners notified whenever the diagnostic set changes. */
|
||||||
|
const diagnosticListeners = new Set<(diagnostics: ReactivityDiagnostic[]) => void>();
|
||||||
|
|
||||||
|
function errorText(e: unknown): string {
|
||||||
|
return e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticId(d: ReactivityDiagnostic): string {
|
||||||
|
return [d.kind, d.key ?? "", d.expression ?? "", d.source ?? "", d.tag ?? "", d.message].join("\u0000");
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyDiagnostics(): void {
|
||||||
|
const list = [...diagnostics.values()];
|
||||||
|
for (const listener of diagnosticListeners) listener(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a diagnostic, dedupe by identity, and notify listeners. */
|
||||||
|
function reportDiagnostic(d: ReactivityDiagnostic): void {
|
||||||
|
const id = diagnosticId(d);
|
||||||
|
if (diagnostics.has(id)) return;
|
||||||
|
diagnostics.set(id, d);
|
||||||
|
notifyDiagnostics();
|
||||||
|
console.warn(`[var-reactivity] ${d.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostics collected by the engine (bad declarations, modifier expressions,
|
||||||
|
* circular dependencies). Cleared by `initReactivity` so a re-init reports a
|
||||||
|
* fresh set. Repeated failures of the same expression are reported once.
|
||||||
|
*/
|
||||||
|
export function getReactivityDiagnostics(): ReactivityDiagnostic[] {
|
||||||
|
return [...diagnostics.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all collected diagnostics. */
|
||||||
|
export function clearReactivityDiagnostics(): void {
|
||||||
|
if (diagnostics.size === 0) return;
|
||||||
|
diagnostics.clear();
|
||||||
|
notifyDiagnostics();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to diagnostic changes. The listener receives the current list on
|
||||||
|
* every change. Returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
export function onReactivityDiagnostics(
|
||||||
|
listener: (diagnostics: ReactivityDiagnostic[]) => void,
|
||||||
|
): () => void {
|
||||||
|
diagnosticListeners.add(listener);
|
||||||
|
return () => diagnosticListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public API
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -129,42 +216,64 @@ const inFlight = new Set<string>();
|
|||||||
* Initialize (or re-initialize) the reactivity engine from declarations
|
* Initialize (or re-initialize) the reactivity engine from declarations
|
||||||
* and tag modifiers parsed from role=declare blocks.
|
* and tag modifiers parsed from role=declare blocks.
|
||||||
*
|
*
|
||||||
|
* The new state is built into locals and only committed once validation
|
||||||
|
* passes, so a throw (e.g. a circular dependency) leaves the previously
|
||||||
|
* initialized engine intact rather than half-populated.
|
||||||
|
*
|
||||||
* Throws if a circular dependency is detected.
|
* Throws if a circular dependency is detected.
|
||||||
*/
|
*/
|
||||||
export function initReactivity(state: VarReactivityState): void {
|
export function initReactivity(state: VarReactivityState): void {
|
||||||
depGraph = new Map();
|
const nextTagModMap = new Map<
|
||||||
declExprs = new Map();
|
string,
|
||||||
tagModMap = new Map();
|
Array<{ target: string; expression: string; threshold: number }>
|
||||||
baseValues.clear();
|
>();
|
||||||
numericMods.clear();
|
const nextDeclExprs = new Map<string, string>();
|
||||||
tagMapMods.clear();
|
const nextDepGraph = new Map<string, Set<string>>();
|
||||||
sourceActivations.clear();
|
|
||||||
|
|
||||||
// Index tag modifiers
|
// Index tag modifiers
|
||||||
for (const tm of state.tagModifiers) {
|
for (const tm of state.tagModifiers) {
|
||||||
let list = tagModMap.get(tm.tag);
|
let list = nextTagModMap.get(tm.tag);
|
||||||
if (!list) {
|
if (!list) {
|
||||||
list = [];
|
list = [];
|
||||||
tagModMap.set(tm.tag, list);
|
nextTagModMap.set(tm.tag, list);
|
||||||
}
|
}
|
||||||
list.push({ target: tm.target, expression: tm.expression, threshold: tm.threshold });
|
list.push({ target: tm.target, expression: tm.expression, threshold: tm.threshold });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index declarations and build dependency graph
|
// Index declarations and build dependency graph
|
||||||
for (const decl of state.declarations) {
|
for (const decl of state.declarations) {
|
||||||
declExprs.set(decl.key, decl.expression);
|
nextDeclExprs.set(decl.key, decl.expression);
|
||||||
const deps = extractDependencies(decl.expression);
|
const deps = extractDependencies(decl.expression);
|
||||||
for (const dep of deps) {
|
for (const dep of deps) {
|
||||||
let dependents = depGraph.get(dep);
|
let dependents = nextDepGraph.get(dep);
|
||||||
if (!dependents) {
|
if (!dependents) {
|
||||||
dependents = new Set();
|
dependents = new Set();
|
||||||
depGraph.set(dep, dependents);
|
nextDepGraph.set(dep, dependents);
|
||||||
}
|
}
|
||||||
dependents.add(decl.key);
|
dependents.add(decl.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkCircular();
|
try {
|
||||||
|
checkCircular(nextDeclExprs);
|
||||||
|
} catch (e) {
|
||||||
|
reportDiagnostic({
|
||||||
|
kind: "init",
|
||||||
|
message: `Declarations contain an unresolvable dependency: ${errorText(e)}`,
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation passed — commit the new configuration and reset runtime state.
|
||||||
|
depGraph = nextDepGraph;
|
||||||
|
declExprs = nextDeclExprs;
|
||||||
|
tagModMap = nextTagModMap;
|
||||||
|
baseValues.clear();
|
||||||
|
numericMods.clear();
|
||||||
|
tagMapMods.clear();
|
||||||
|
sourceActivations.clear();
|
||||||
|
inFlight.clear();
|
||||||
|
clearReactivityDiagnostics();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract $var names from an expression string. */
|
/** Extract $var names from an expression string. */
|
||||||
@@ -222,6 +331,24 @@ export function getCombined(key: string, fallback?: VariableStore): string {
|
|||||||
return "0";
|
return "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a `$var` to its combined value, distinguishing an unknown variable
|
||||||
|
* (undefined) from a real value of "0". Strict contexts such as `/set` and
|
||||||
|
* `/roll` use this so a typo'd or undeclared reference errors instead of
|
||||||
|
* silently evaluating to 0.
|
||||||
|
*/
|
||||||
|
export function resolveVariable(
|
||||||
|
key: string,
|
||||||
|
fallback?: VariableStore,
|
||||||
|
): string | undefined {
|
||||||
|
const known =
|
||||||
|
baseValues.has(key) ||
|
||||||
|
numericMods.has(key) ||
|
||||||
|
tagMapMods.has(key) ||
|
||||||
|
(fallback !== undefined && fallback[key] !== undefined);
|
||||||
|
return known ? getCombined(key, fallback) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the effective tagmap for a variable (base + tagmap mods).
|
* Get the effective tagmap for a variable (base + tagmap mods).
|
||||||
* Returns null if the variable is not a tagmap variable.
|
* Returns null if the variable is not a tagmap variable.
|
||||||
@@ -265,22 +392,34 @@ export function setBase(key: string, value: string): void {
|
|||||||
* from the hydrated stream variable store. Must be called after replayReducers
|
* from the hydrated stream variable store. Must be called after replayReducers
|
||||||
* so that tag activations are correctly tracked for subsequent cascade
|
* so that tag activations are correctly tracked for subsequent cascade
|
||||||
* computations.
|
* computations.
|
||||||
|
*
|
||||||
|
* The stream store holds *combined* values (base + active modifiers) alongside
|
||||||
|
* the raw `base` each was derived from, so bases are restored exactly rather
|
||||||
|
* than reconstructed by subtracting modifiers back out.
|
||||||
*/
|
*/
|
||||||
export function rebuildReactivityFromStore(variables: VariableStore): void {
|
export function rebuildReactivityFromStore(
|
||||||
|
variables: VariableStore,
|
||||||
|
bases?: VariableStore,
|
||||||
|
): void {
|
||||||
if (!tagModMap) return;
|
if (!tagModMap) return;
|
||||||
|
|
||||||
|
// Reset, then restore exact bases from the store's base map.
|
||||||
|
baseValues.clear();
|
||||||
|
numericMods.clear();
|
||||||
|
tagMapMods.clear();
|
||||||
|
sourceActivations.clear();
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(variables)) {
|
for (const [key, value] of Object.entries(variables)) {
|
||||||
if (!baseValues.has(key)) {
|
baseValues.set(key, bases?.[key] ?? value);
|
||||||
baseValues.set(key, value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagMap = isTagMapValue(value) ? parseTagMap(value) : null;
|
// Re-attach tag modifiers from each source's *base* tagmap. Using the
|
||||||
if (tagMap && !sourceActivations.has(key)) {
|
// combined value here would re-trigger a modifier that merely added a tag to
|
||||||
// Activate all tags in the tagmap
|
// the source (feedback), inflating counts on every rebuild.
|
||||||
const added = applyTagMapActivations(key, {}, tagMap, variables);
|
for (const [key, value] of Object.entries(variables)) {
|
||||||
// We don't return the added entries here — they'll be applied
|
const source = bases?.[key] ?? value;
|
||||||
// to the store separately
|
const tagMap = isTagMapValue(source) ? parseTagMap(source) : null;
|
||||||
}
|
if (tagMap) applyTagMapActivations(key, {}, tagMap, variables);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,12 +435,12 @@ export function computeCascade(
|
|||||||
changedVar: string,
|
changedVar: string,
|
||||||
oldValue: string | undefined,
|
oldValue: string | undefined,
|
||||||
currentVars: VariableStore,
|
currentVars: VariableStore,
|
||||||
): Array<{ key: string; value: string }> {
|
): VarChange[] {
|
||||||
if (!depGraph || !declExprs || !tagModMap) {
|
if (!depGraph || !declExprs || !tagModMap) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: Array<{ key: string; value: string }> = [];
|
const results: VarChange[] = [];
|
||||||
|
|
||||||
// ---- Tag activation/deactivation ----
|
// ---- Tag activation/deactivation ----
|
||||||
const newValue = getCombined(changedVar, currentVars);
|
const newValue = getCombined(changedVar, currentVars);
|
||||||
@@ -325,28 +464,35 @@ export function computeCascade(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute initial values for all declared variables.
|
* Compute initial values for all declared variables.
|
||||||
* Called once after initReactivity() to seed the store.
|
* Called after initReactivity() to seed the store.
|
||||||
|
*
|
||||||
|
* @param existing - declared keys already present in the store; these are
|
||||||
|
* skipped so hydrated/overridden session values are not clobbered.
|
||||||
*/
|
*/
|
||||||
export function computeInitialValues(
|
export function computeInitialValues(
|
||||||
currentVars: VariableStore,
|
currentVars: VariableStore,
|
||||||
): Array<{ key: string; value: string }> {
|
existing?: ReadonlySet<string>,
|
||||||
|
): VarChange[] {
|
||||||
if (!declExprs) return [];
|
if (!declExprs) return [];
|
||||||
|
|
||||||
const allKeys = [...declExprs.keys()];
|
const allKeys = [...declExprs.keys()];
|
||||||
const sorted = topoSortAffected(new Set(allKeys));
|
const sorted = topoSortAffected(new Set(allKeys));
|
||||||
|
|
||||||
const results: Array<{ key: string; value: string }> = [];
|
const results: VarChange[] = [];
|
||||||
|
|
||||||
for (const key of sorted) {
|
for (const key of sorted) {
|
||||||
|
// Variables already in the store were seeded (or overridden) previously;
|
||||||
|
// recomputing them would clobber session state on every reload.
|
||||||
|
if (existing?.has(key)) continue;
|
||||||
|
|
||||||
const expr = declExprs.get(key);
|
const expr = declExprs.get(key);
|
||||||
if (!expr) continue;
|
if (!expr) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = evaluateExpression(expr, {
|
const result = evaluateExpression(expr, {
|
||||||
lookup: (name: string) => {
|
// Declarations may reference runtime/external variables; getCombined
|
||||||
const k = "$" + name;
|
// defaults unknown keys to "0" rather than erroring.
|
||||||
return getCombined(k, currentVars);
|
lookup: (name) => getCombined(name, currentVars),
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawValue = exprValueToString(result.value);
|
const rawValue = exprValueToString(result.value);
|
||||||
@@ -366,10 +512,15 @@ export function computeInitialValues(
|
|||||||
// Always emit the combined value for this key
|
// Always emit the combined value for this key
|
||||||
const combined = getCombined(key, currentVars);
|
const combined = getCombined(key, currentVars);
|
||||||
if (!results.some((x) => x.key === key)) {
|
if (!results.some((x) => x.key === key)) {
|
||||||
results.push({ key, value: combined });
|
results.push({ key, value: combined, base: rawValue });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// skip failed evaluations at init time
|
reportDiagnostic({
|
||||||
|
kind: "declaration",
|
||||||
|
key,
|
||||||
|
expression: expr,
|
||||||
|
message: `Declaration ${key} failed to evaluate "${expr}": ${errorText(e)}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,10 +540,10 @@ function applyTagMapActivations(
|
|||||||
oldTagMap: Record<string, number>,
|
oldTagMap: Record<string, number>,
|
||||||
newTagMap: Record<string, number>,
|
newTagMap: Record<string, number>,
|
||||||
fallback: VariableStore,
|
fallback: VariableStore,
|
||||||
): Array<{ key: string; value: string }> {
|
): VarChange[] {
|
||||||
if (!tagModMap) return [];
|
if (!tagModMap) return [];
|
||||||
|
|
||||||
const results: Array<{ key: string; value: string }> = [];
|
const results: VarChange[] = [];
|
||||||
const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]);
|
const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]);
|
||||||
|
|
||||||
for (const tag of allTags) {
|
for (const tag of allTags) {
|
||||||
@@ -416,10 +567,8 @@ function applyTagMapActivations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const evalResult = evaluateExpression(mod.expression, {
|
const evalResult = evaluateExpression(mod.expression, {
|
||||||
lookup: (name: string) => {
|
// Modifier expressions tolerate undefined references (→ "0").
|
||||||
const k = "$" + name;
|
lookup: (name) => getCombined(name, fallback),
|
||||||
return getCombined(k, fallback);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Modifier expressions must evaluate to a number
|
// Modifier expressions must evaluate to a number
|
||||||
@@ -467,14 +616,24 @@ function applyTagMapActivations(
|
|||||||
|
|
||||||
// Emit new combined value
|
// Emit new combined value
|
||||||
const combined = getCombined(mod.target, fallback);
|
const combined = getCombined(mod.target, fallback);
|
||||||
|
const base = baseValues.get(mod.target) ?? combined;
|
||||||
const existing = results.findIndex((r) => r.key === mod.target);
|
const existing = results.findIndex((r) => r.key === mod.target);
|
||||||
if (existing >= 0) {
|
if (existing >= 0) {
|
||||||
results[existing] = { key: mod.target, value: combined };
|
results[existing] = { key: mod.target, value: combined, base };
|
||||||
} else {
|
} else {
|
||||||
results.push({ key: mod.target, value: combined });
|
results.push({ key: mod.target, value: combined, base });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// skip failed modifier
|
reportDiagnostic({
|
||||||
|
kind: "modifier",
|
||||||
|
key: mod.target,
|
||||||
|
expression: mod.expression,
|
||||||
|
tag,
|
||||||
|
source,
|
||||||
|
message:
|
||||||
|
`Modifier ${tag} → ${mod.target} failed to evaluate ` +
|
||||||
|
`"${mod.expression}" (activated by ${source}): ${errorText(e)}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else if (wasActive && !isActive) {
|
} else if (wasActive && !isActive) {
|
||||||
// Deactivate this modifier
|
// Deactivate this modifier
|
||||||
@@ -512,11 +671,12 @@ function applyTagMapActivations(
|
|||||||
|
|
||||||
// Emit updated combined value
|
// Emit updated combined value
|
||||||
const combined = getCombined(entry.target, fallback);
|
const combined = getCombined(entry.target, fallback);
|
||||||
|
const base = baseValues.get(entry.target) ?? combined;
|
||||||
const existing = results.findIndex((r) => r.key === entry.target);
|
const existing = results.findIndex((r) => r.key === entry.target);
|
||||||
if (existing >= 0) {
|
if (existing >= 0) {
|
||||||
results[existing] = { key: entry.target, value: combined };
|
results[existing] = { key: entry.target, value: combined, base };
|
||||||
} else {
|
} else {
|
||||||
results.push({ key: entry.target, value: combined });
|
results.push({ key: entry.target, value: combined, base });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -537,7 +697,7 @@ function applyTagMapActivations(
|
|||||||
function reevaluateDependents(
|
function reevaluateDependents(
|
||||||
changedVar: string,
|
changedVar: string,
|
||||||
fallback: VariableStore,
|
fallback: VariableStore,
|
||||||
): Array<{ key: string; value: string }> {
|
): VarChange[] {
|
||||||
if (!depGraph || !declExprs) return [];
|
if (!depGraph || !declExprs) return [];
|
||||||
|
|
||||||
// Collect all dependents reachable from changedVar (BFS)
|
// Collect all dependents reachable from changedVar (BFS)
|
||||||
@@ -556,7 +716,7 @@ function reevaluateDependents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sorted = topoSortAffected(affected);
|
const sorted = topoSortAffected(affected);
|
||||||
const results: Array<{ key: string; value: string }> = [];
|
const results: VarChange[] = [];
|
||||||
|
|
||||||
for (const key of sorted) {
|
for (const key of sorted) {
|
||||||
if (inFlight.has(key)) {
|
if (inFlight.has(key)) {
|
||||||
@@ -571,10 +731,8 @@ function reevaluateDependents(
|
|||||||
if (!expr) continue;
|
if (!expr) continue;
|
||||||
|
|
||||||
const result = evaluateExpression(expr, {
|
const result = evaluateExpression(expr, {
|
||||||
lookup: (name: string) => {
|
// Declaration re-evaluation stays lenient for undeclared references.
|
||||||
const k = "$" + name;
|
lookup: (name) => getCombined(name, fallback),
|
||||||
return getCombined(k, fallback);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawValue = exprValueToString(result.value);
|
const rawValue = exprValueToString(result.value);
|
||||||
@@ -599,8 +757,16 @@ function reevaluateDependents(
|
|||||||
// Emit combined value
|
// Emit combined value
|
||||||
const combined = getCombined(key, fallback);
|
const combined = getCombined(key, fallback);
|
||||||
if (!results.some((x) => x.key === key)) {
|
if (!results.some((x) => x.key === key)) {
|
||||||
results.push({ key, value: combined });
|
results.push({ key, value: combined, base: rawValue });
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Report the bad declaration but keep evaluating unrelated dependents.
|
||||||
|
reportDiagnostic({
|
||||||
|
kind: "declaration",
|
||||||
|
key,
|
||||||
|
expression: declExprs.get(key),
|
||||||
|
message: `Declaration ${key} failed to re-evaluate "${declExprs.get(key) ?? ""}": ${errorText(e)}`,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
inFlight.delete(key);
|
inFlight.delete(key);
|
||||||
}
|
}
|
||||||
@@ -631,7 +797,10 @@ function topoSortAffected(affected: Set<string>): string[] {
|
|||||||
if (expr) {
|
if (expr) {
|
||||||
const deps = extractDependencies(expr);
|
const deps = extractDependencies(expr);
|
||||||
for (const dep of deps) {
|
for (const dep of deps) {
|
||||||
if (affected.has(dep) || declExprs!.has(dep)) {
|
// Only order within the affected set. Declared dependencies that did
|
||||||
|
// not change must not be pulled in, or reevaluateDependents would
|
||||||
|
// re-publish variables that were never touched.
|
||||||
|
if (affected.has(dep)) {
|
||||||
visit(dep);
|
visit(dep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -649,10 +818,8 @@ function topoSortAffected(affected: Set<string>): string[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkCircular(): void {
|
function checkCircular(exprs: Map<string, string>): void {
|
||||||
if (!declExprs) return;
|
const allKeys = [...exprs.keys()];
|
||||||
|
|
||||||
const allKeys = [...declExprs.keys()];
|
|
||||||
const state = new Map<string, "unvisited" | "visiting" | "visited">();
|
const state = new Map<string, "unvisited" | "visiting" | "visited">();
|
||||||
for (const k of allKeys) state.set(k, "unvisited");
|
for (const k of allKeys) state.set(k, "unvisited");
|
||||||
|
|
||||||
@@ -672,11 +839,11 @@ function checkCircular(): void {
|
|||||||
state.set(key, "visiting");
|
state.set(key, "visiting");
|
||||||
path.push(key);
|
path.push(key);
|
||||||
|
|
||||||
const expr = declExprs!.get(key);
|
const expr = exprs.get(key);
|
||||||
if (expr) {
|
if (expr) {
|
||||||
const deps = extractDependencies(expr);
|
const deps = extractDependencies(expr);
|
||||||
for (const dep of deps) {
|
for (const dep of deps) {
|
||||||
if (declExprs!.has(dep)) {
|
if (exprs.has(dep)) {
|
||||||
dfs(dep);
|
dfs(dep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* - Number literals (integer or decimal)
|
* - Number literals (integer or decimal)
|
||||||
* - Tagmap literals: #warrior:1;#druid:2 (bare #warrior → #warrior:1)
|
* - Tagmap literals: #warrior:1;#druid:2 (bare #warrior → #warrior:1)
|
||||||
* - $var references (resolved via lookup; auto-detects number vs tagmap)
|
* - $var references (resolved via lookup; auto-detects number vs tagmap)
|
||||||
* - Dice patterns: 3d6, 2d8kh1, etc. (delegates to rollFormula)
|
* - Dice patterns: 3d6, 2d8kh1, d20, etc. (delegates to rollFormula)
|
||||||
* - Arithmetic: + - * / (type-checked via registry)
|
* - Arithmetic: + - * / (type-checked via registry)
|
||||||
* - Functions: floor(x), ceil(x), round(x) (numbers only)
|
* - Functions: floor(x), ceil(x), round(x) (numbers only)
|
||||||
* - Parentheses for grouping
|
* - Parentheses for grouping
|
||||||
@@ -16,10 +16,12 @@
|
|||||||
* - Circular variable references (detected by caller)
|
* - Circular variable references (detected by caller)
|
||||||
* - Division by zero
|
* - Division by zero
|
||||||
* - Unknown functions
|
* - Unknown functions
|
||||||
|
* - Unknown variables (lookup returned undefined)
|
||||||
* - Malformed expressions
|
* - Malformed expressions
|
||||||
|
* - Malformed numeric literals (e.g. "1.2.3")
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { rollFormula } from "../md-commander/hooks";
|
import { rollFormula } from "../dice";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -31,8 +33,13 @@ export type ExprValue =
|
|||||||
| { kind: "tagmap"; value: Record<string, number> };
|
| { kind: "tagmap"; value: Record<string, number> };
|
||||||
|
|
||||||
export interface EvalContext {
|
export interface EvalContext {
|
||||||
/** Resolve $var → string (numeric or tagmap serialized form).
|
/** Resolve a variable reference — the full name including the leading `$`
|
||||||
* Return undefined if the variable doesn't exist. */
|
* (e.g. `"$hp"`) — to its serialized value: numeric ("12") or tagmap
|
||||||
|
* ("#warrior:1;#druid:2").
|
||||||
|
*
|
||||||
|
* Return undefined for an unknown variable; the evaluator then throws
|
||||||
|
* `Unknown variable`. Contexts that want a default (e.g. undeclared
|
||||||
|
* variables in declarations resolving to 0) should return a value instead. */
|
||||||
lookup: (varName: string) => string | undefined;
|
lookup: (varName: string) => string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +175,9 @@ interface Token {
|
|||||||
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
|
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
|
||||||
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
|
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
|
||||||
|
|
||||||
|
/** Numeric literal: integer or decimal with digits on both sides of the dot. */
|
||||||
|
const NUMBER_RE = /^\d+(?:\.\d+)?$/;
|
||||||
|
|
||||||
/** Single tagmap entry: "#warrior" or "#warrior:1" */
|
/** Single tagmap entry: "#warrior" or "#warrior:1" */
|
||||||
const TAGMAP_ENTRY_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*)(?::(\d+))?/;
|
const TAGMAP_ENTRY_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*)(?::(\d+))?/;
|
||||||
|
|
||||||
@@ -205,10 +215,32 @@ function tokenize(input: string): Token[] {
|
|||||||
tokens.push({ kind: "number", value: String(rollDice(dice)), raw: dice });
|
tokens.push({ kind: "number", value: String(rollDice(dice)), raw: dice });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// The scanner above consumes digits and dots greedily, so "1.2.3" and
|
||||||
|
// "1." arrive here as a single chunk. Reject them rather than letting
|
||||||
|
// parseFloat silently truncate to a wrong value.
|
||||||
|
if (!NUMBER_RE.test(num)) {
|
||||||
|
throw new Error(`Invalid number literal: "${num}"`);
|
||||||
|
}
|
||||||
tokens.push({ kind: "number", value: num, raw: num });
|
tokens.push({ kind: "number", value: num, raw: num });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dice pattern without a leading count: e.g. "d20", "d6kh1". The number
|
||||||
|
// branch above only catches dice that start with a digit ("3d6"), so bare
|
||||||
|
// forms need their own branch before the generic identifier branch.
|
||||||
|
if (/[dD]/.test(ch) && i + 1 < input.length && /[0-9]/.test(input[i + 1])) {
|
||||||
|
let dice = "";
|
||||||
|
while (i < input.length && /[a-zA-Z0-9]/.test(input[i])) {
|
||||||
|
dice += input[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (!DICE_RE.test(dice)) {
|
||||||
|
throw new Error(`Invalid dice notation: "${dice}"`);
|
||||||
|
}
|
||||||
|
tokens.push({ kind: "number", value: String(rollDice(dice)), raw: dice });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Tagmap literal: #warrior:1;#druid:2 or bare #warrior
|
// Tagmap literal: #warrior:1;#druid:2 or bare #warrior
|
||||||
if (ch === "#") {
|
if (ch === "#") {
|
||||||
let raw = "";
|
let raw = "";
|
||||||
@@ -411,9 +443,9 @@ function parseFactor(
|
|||||||
// Variable reference: $var
|
// Variable reference: $var
|
||||||
if (tok.kind === "var") {
|
if (tok.kind === "var") {
|
||||||
const varName = tok.value; // includes $ prefix
|
const varName = tok.value; // includes $ prefix
|
||||||
const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup
|
const resolved = ctx.lookup(varName);
|
||||||
if (resolved === undefined) {
|
if (resolved === undefined) {
|
||||||
return { value: { kind: "number", value: 0 }, next: pos + 1 };
|
throw new Error(`Unknown variable: ${varName}`);
|
||||||
}
|
}
|
||||||
// Auto-detect: tagmap or numeric
|
// Auto-detect: tagmap or numeric
|
||||||
if (resolved.startsWith("#")) {
|
if (resolved.startsWith("#")) {
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
import { type Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js";
|
|
||||||
import type { TrackerAttribute } from "./types";
|
|
||||||
|
|
||||||
export interface AttributeTooltipProps {
|
|
||||||
position: { x: number; y: number };
|
|
||||||
attributes: Record<string, TrackerAttribute>;
|
|
||||||
classes: string[];
|
|
||||||
onUpdate: (attrName: string, attr: TrackerAttribute) => void;
|
|
||||||
onClassesChange: (classes: string[]) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AttributeTooltip: Component<AttributeTooltipProps> = (props) => {
|
|
||||||
const [position, setPosition] = createSignal(props.position);
|
|
||||||
const [newClass, setNewClass] = createSignal("");
|
|
||||||
|
|
||||||
// 点击外部关闭
|
|
||||||
createEffect(() => {
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('body') && !target.closest(".attribute-tooltip")) {
|
|
||||||
props.onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("click", handleClickOutside);
|
|
||||||
onCleanup(() => document.removeEventListener("click", handleClickOutside));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 处理属性更新
|
|
||||||
const handleUpdate = (attrName: string, value: any) => {
|
|
||||||
const attr = props.attributes[attrName];
|
|
||||||
props.onUpdate(attrName, { ...attr, value });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理滚轮增减(progress 和 count 类型)
|
|
||||||
const handleWheel = (e: WheelEvent, attrName: string, attr: TrackerAttribute) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (attr.type === "count") {
|
|
||||||
const delta = e.deltaY > 0 ? -1 : 1;
|
|
||||||
const newValue = Math.max(0, (attr.value as number) + delta);
|
|
||||||
handleUpdate(attrName, newValue);
|
|
||||||
} else if (attr.type === "progress") {
|
|
||||||
const val = attr.value as { x: number; y: number };
|
|
||||||
const delta = e.deltaY > 0 ? -1 : 1;
|
|
||||||
// 滚轮向上/下调整当前值 x
|
|
||||||
const newX = Math.max(0, Math.min(val.y, val.x + delta));
|
|
||||||
handleUpdate(attrName, { x: newX, y: val.y });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加 class
|
|
||||||
const handleAddClass = () => {
|
|
||||||
const className = newClass().trim();
|
|
||||||
if (className && !props.classes.includes(className)) {
|
|
||||||
props.onClassesChange([...props.classes, className]);
|
|
||||||
setNewClass("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 移除 class
|
|
||||||
const handleRemoveClass = (e: MouseEvent, className: string) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
props.onClassesChange(props.classes.filter((c) => c !== className));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染不同类型的属性编辑器
|
|
||||||
const renderEditor = (attrName: string, attr: TrackerAttribute) => {
|
|
||||||
if (attr.type === "progress") {
|
|
||||||
const val = attr.value as { x: number; y: number };
|
|
||||||
const percentage = val.y > 0 ? (val.x / val.y) * 100 : 0;
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
{/* 进度条 */}
|
|
||||||
<div
|
|
||||||
class="w-full h-4 bg-gray-200 rounded-full overflow-hidden cursor-pointer"
|
|
||||||
title="滚动鼠标滚轮增减"
|
|
||||||
onWheel={(e) => handleWheel(e, attrName, attr)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="h-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-200"
|
|
||||||
style={{ width: `${Math.min(100, percentage)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* 输入框 */}
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={val.x}
|
|
||||||
onChange={(e) => handleUpdate(attrName, { x: parseInt(e.target.value) || 0, y: val.y })}
|
|
||||||
onWheel={(e) => handleWheel(e, attrName, attr)}
|
|
||||||
class="w-14 px-1 py-0.5 border border-gray-300 rounded text-center text-sm"
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
<span class="text-gray-500">/</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={val.y}
|
|
||||||
onChange={(e) => handleUpdate(attrName, { x: val.x, y: parseInt(e.target.value) || 0 })}
|
|
||||||
class="w-14 px-1 py-0.5 border border-gray-300 rounded text-center text-sm"
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attr.type === "count") {
|
|
||||||
const value = attr.value as number;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1"
|
|
||||||
onWheel={(e) => handleWheel(e, attrName, attr)}
|
|
||||||
title="滚动鼠标滚轮增减"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="w-7 h-7 flex items-center justify-center bg-gray-200 hover:bg-gray-300 rounded text-lg font-bold transition-colors"
|
|
||||||
onClick={() => handleUpdate(attrName, value - 1)}
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => handleUpdate(attrName, parseInt(e.target.value) || 0)}
|
|
||||||
onWheel={(e) => handleWheel(e, attrName, attr)}
|
|
||||||
class="w-12 px-1 py-0.5 border border-gray-300 rounded text-center text-sm"
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="w-7 h-7 flex items-center justify-center bg-gray-200 hover:bg-gray-300 rounded text-lg font-bold transition-colors"
|
|
||||||
onClick={() => handleUpdate(attrName, value + 1)}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attr.type === "string") {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={attr.value as string}
|
|
||||||
onChange={(e) => handleUpdate(attrName, e.target.value)}
|
|
||||||
class="w-full px-2 py-1 border border-gray-300 rounded text-sm"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <span class="text-gray-500">未知类型</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="attribute-tooltip fixed z-50 bg-white border border-gray-300 rounded-lg shadow-xl p-3 min-w-[150px]"
|
|
||||||
style={{
|
|
||||||
left: `${position().x}px`,
|
|
||||||
top: `${position().y}px`,
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between mb-2 pb-2 border-b border-gray-200">
|
|
||||||
<span class="font-bold text-gray-700 text-sm">编辑属性</span>
|
|
||||||
<button
|
|
||||||
class="text-gray-400 hover:text-gray-600 text-lg leading-none"
|
|
||||||
onClick={props.onClose}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-3">
|
|
||||||
{/* Class 列表编辑器 */}
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label class="text-xs text-gray-500 font-medium">Classes</label>
|
|
||||||
<div class="flex flex-wrap gap-1 mb-1">
|
|
||||||
<For each={props.classes}>
|
|
||||||
{(className) => (
|
|
||||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">
|
|
||||||
.{className}
|
|
||||||
<button
|
|
||||||
class="text-blue-400 hover:text-blue-600 font-bold leading-none"
|
|
||||||
onClick={(e) => handleRemoveClass(e, className)}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newClass()}
|
|
||||||
onInput={(e) => setNewClass((e.target as HTMLInputElement).value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleAddClass();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="添加 class..."
|
|
||||||
class="flex-1 px-2 py-1 border border-gray-300 rounded text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 bg-gray-200 hover:bg-gray-300 rounded text-sm font-medium transition-colors"
|
|
||||||
onClick={handleAddClass}
|
|
||||||
>
|
|
||||||
添加
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 属性编辑器 */}
|
|
||||||
<For each={Object.entries(props.attributes)}>
|
|
||||||
{([name, attr]) => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label class="text-xs text-gray-500 font-medium">{name}</label>
|
|
||||||
{renderEditor(name, attr)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { type Component, For, Show } from "solid-js";
|
|
||||||
import type { CommanderEntry } from "./types";
|
|
||||||
import { getResultClass } from "./hooks";
|
|
||||||
|
|
||||||
export interface CommanderEntriesProps {
|
|
||||||
entries: () => CommanderEntry[];
|
|
||||||
onCommandClick?: (command: string) => void;
|
|
||||||
loading?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CommanderEntries: Component<CommanderEntriesProps> = (props) => {
|
|
||||||
let containerRef: HTMLDivElement | undefined;
|
|
||||||
|
|
||||||
const handleCommandClick = (command: string) => {
|
|
||||||
if (props.onCommandClick) {
|
|
||||||
props.onCommandClick(command);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
class="commander-entries flex-1 overflow-auto p-3 bg-white space-y-2"
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={props.loading}
|
|
||||||
fallback={
|
|
||||||
<Show
|
|
||||||
when={props.error}
|
|
||||||
fallback={
|
|
||||||
<Show
|
|
||||||
when={props.entries().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="text-gray-400 text-center py-8">暂无命令执行记录</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={props.entries()}>
|
|
||||||
{(entry) => (
|
|
||||||
<div class="border-l-2 border-gray-300 pl-3 py-1">
|
|
||||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
|
||||||
<span
|
|
||||||
class="font-mono cursor-pointer hover:text-blue-600 hover:underline truncate max-w-64"
|
|
||||||
onClick={() => handleCommandClick(entry.command)}
|
|
||||||
title="点击复制到输入框"
|
|
||||||
>
|
|
||||||
{entry.command}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{entry.timestamp.toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class={`text-sm whitespace-pre-wrap ${getResultClass(entry.result.type)}`}
|
|
||||||
innerHTML={entry.result.isHtml ? entry.result.message : undefined}
|
|
||||||
>
|
|
||||||
{!entry.result.isHtml && entry.result.message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="text-red-500 text-center py-8">{props.error}</div>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-center h-full">
|
|
||||||
<div class="text-gray-500 flex items-center gap-2">
|
|
||||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
|
|
||||||
<span>加载命令模板中...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import {type Component, For, Show, createEffect, on} from "solid-js";
|
|
||||||
import type { CompletionItem } from "./types";
|
|
||||||
|
|
||||||
export interface CommanderInputProps {
|
|
||||||
placeholder?: string;
|
|
||||||
inputValue: () => string;
|
|
||||||
onInput: (e: Event) => void;
|
|
||||||
onKeyDown: (e: KeyboardEvent) => void;
|
|
||||||
onFocus: () => void;
|
|
||||||
onBlur: () => void;
|
|
||||||
onSubmit: () => void;
|
|
||||||
showCompletions: () => boolean;
|
|
||||||
completions: () => CompletionItem[];
|
|
||||||
selectedCompletion: () => number;
|
|
||||||
onSelectCompletion: (idx: number) => void;
|
|
||||||
onAcceptCompletion: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CommanderInput: Component<CommanderInputProps> = (props) => {
|
|
||||||
let containerRef: HTMLDivElement | undefined;
|
|
||||||
|
|
||||||
// 当选中项变化时自动滚动到可见区域
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => props.selectedCompletion(),
|
|
||||||
() => {
|
|
||||||
if (containerRef) {
|
|
||||||
const selectedItem = containerRef.querySelector('[data-selected="true"]');
|
|
||||||
if (selectedItem) {
|
|
||||||
selectedItem.scrollIntoView({ block: "nearest" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const maxVisible = 3;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex items-center border-b border-gray-300 bg-gray-50 px-3 py-2">
|
|
||||||
<span class="text-gray-500 mr-2">❯</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={props.inputValue()}
|
|
||||||
onInput={props.onInput}
|
|
||||||
onKeyDown={props.onKeyDown}
|
|
||||||
onFocus={props.onFocus}
|
|
||||||
onBlur={props.onBlur}
|
|
||||||
placeholder={props.placeholder || "输入命令..."}
|
|
||||||
class="flex-1 bg-transparent outline-none text-gray-800"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={props.onSubmit}
|
|
||||||
class="ml-2 px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
执行
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 自动补全下拉框 - 向上弹出 */}
|
|
||||||
<Show when={props.showCompletions() && props.completions().length > 0}>
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
class="absolute left-0 bottom-full w-full bg-white border border-gray-300 shadow-lg mb-1 overflow-auto"
|
|
||||||
style={{ "max-height": `${maxVisible * 40}px` }}
|
|
||||||
>
|
|
||||||
<For each={props.completions()}>{(comp, idx) => (
|
|
||||||
<div
|
|
||||||
data-selected={idx() === props.selectedCompletion()}
|
|
||||||
class={`px-3 py-2 cursor-pointer flex justify-between items-center ${
|
|
||||||
idx() === props.selectedCompletion()
|
|
||||||
? "bg-blue-100"
|
|
||||||
: "hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
props.onSelectCompletion(idx());
|
|
||||||
props.onAcceptCompletion();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
class={`text-xs px-1 rounded ${
|
|
||||||
comp.type === "command"
|
|
||||||
? "bg-purple-100 text-purple-700"
|
|
||||||
: comp.type === "option"
|
|
||||||
? "bg-green-100 text-green-700"
|
|
||||||
: "bg-gray-100 text-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{comp.type}
|
|
||||||
</span>
|
|
||||||
<span class="font-mono text-sm">{comp.label}</span>
|
|
||||||
</div>
|
|
||||||
<Show when={comp.description}>
|
|
||||||
<span class="text-xs text-gray-500">{comp.description}</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { type Component } from "solid-js";
|
|
||||||
import type { TrackerViewMode } from "./types";
|
|
||||||
|
|
||||||
export interface TabBarProps {
|
|
||||||
mode: () => TrackerViewMode;
|
|
||||||
onModeChange: (mode: TrackerViewMode) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TabBar: Component<TabBarProps> = (props) => {
|
|
||||||
return (
|
|
||||||
<div class="flex lg:hidden border-b border-gray-300 bg-gray-50">
|
|
||||||
<button
|
|
||||||
class={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
|
||||||
props.mode() === "history"
|
|
||||||
? "bg-white text-blue-600 border-b-2 border-blue-600"
|
|
||||||
: "text-gray-600 hover:text-gray-800 hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
onClick={() => props.onModeChange("history")}
|
|
||||||
>
|
|
||||||
📜 历史
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
|
||||||
props.mode() === "tracker"
|
|
||||||
? "bg-white text-blue-600 border-b-2 border-blue-600"
|
|
||||||
: "text-gray-600 hover:text-gray-800 hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
onClick={() => props.onModeChange("tracker")}
|
|
||||||
>
|
|
||||||
📋 追踪
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import { type Component, For, Show, createSignal } from "solid-js";
|
|
||||||
import type { TrackerItem, TrackerAttribute } from "./types";
|
|
||||||
import { AttributeTooltip } from "./AttributeTooltip";
|
|
||||||
|
|
||||||
export interface TrackerViewProps {
|
|
||||||
items: () => TrackerItem[];
|
|
||||||
onEditAttribute?: (index: number, attrName: string, attr: TrackerAttribute) => void;
|
|
||||||
onClassesChange?: (index: number, classes: string[]) => void;
|
|
||||||
onRemoveClass?: (index: number, className: string) => void;
|
|
||||||
onMoveUp?: (index: number) => void;
|
|
||||||
onMoveDown?: (index: number) => void;
|
|
||||||
onRemove?: (index: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TrackerView: Component<TrackerViewProps> = (props) => {
|
|
||||||
const [editingItem, setEditingItem] = createSignal<{
|
|
||||||
index: number;
|
|
||||||
position: { x: number; y: number };
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const formatAttributeValue = (attr: TrackerAttribute): string => {
|
|
||||||
if (attr.type === "progress") {
|
|
||||||
const val = attr.value as { x: number; y: number };
|
|
||||||
return `${val.x}/${val.y}`;
|
|
||||||
}
|
|
||||||
if (attr.type === "count") {
|
|
||||||
return String(attr.value);
|
|
||||||
}
|
|
||||||
return String(attr.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatAttributeShort = (name: string, attr: TrackerAttribute): string => {
|
|
||||||
const val = formatAttributeValue(attr);
|
|
||||||
return `${name}=${val}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理滚轮增减(progress 和 count 类型)
|
|
||||||
const handleWheel = (e: WheelEvent, index: number, attrName: string, attr: TrackerAttribute) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (attr.type === "count") {
|
|
||||||
const delta = e.deltaY > 0 ? -1 : 1;
|
|
||||||
const newValue = Math.max(0, (attr.value as number) + delta);
|
|
||||||
props.onEditAttribute?.(index, attrName, { ...attr, value: newValue });
|
|
||||||
} else if (attr.type === "progress") {
|
|
||||||
const val = attr.value as { x: number; y: number };
|
|
||||||
const delta = e.deltaY > 0 ? -1 : 1;
|
|
||||||
const newX = Math.max(0, Math.min(val.y, val.x + delta));
|
|
||||||
props.onEditAttribute?.(index, attrName, { ...attr, value: { x: newX, y: val.y } });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTagClick = (e: MouseEvent, index: number) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
||||||
setEditingItem({
|
|
||||||
index,
|
|
||||||
position: {
|
|
||||||
x: rect.left,
|
|
||||||
y: rect.bottom + 5,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateAttribute = (attrName: string, attr: TrackerAttribute) => {
|
|
||||||
const data = editingItem();
|
|
||||||
if (data) {
|
|
||||||
props.onEditAttribute?.(data.index, attrName, attr);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClassesChange = (classes: string[]) => {
|
|
||||||
const data = editingItem();
|
|
||||||
if (data) {
|
|
||||||
props.onClassesChange?.(data.index, classes);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="tracker-view flex-1 overflow-auto p-3 bg-white">
|
|
||||||
<Show
|
|
||||||
when={props.items().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="text-gray-400 text-center py-8">暂无追踪项目</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<For each={props.items()}>
|
|
||||||
{(item, index) => (
|
|
||||||
<div class="border border-gray-200 rounded-lg p-2 bg-gray-50">
|
|
||||||
{/* 第一行:tag#id.class + 操作按钮 */}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
|
|
||||||
<button
|
|
||||||
class="font-bold text-gray-800 whitespace-nowrap hover:text-blue-600 hover:underline cursor-pointer transition-colors"
|
|
||||||
title="点击编辑属性"
|
|
||||||
onClick={(e) => handleTagClick(e, index())}
|
|
||||||
>
|
|
||||||
{item.tag}
|
|
||||||
</button>
|
|
||||||
<Show when={item.id}>
|
|
||||||
<span class="text-xs text-purple-600 font-mono whitespace-nowrap">#{item.id}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.classes.length > 0}>
|
|
||||||
<span class="text-xs text-blue-600 font-mono whitespace-nowrap">.{item.classes.join(".")}</span>
|
|
||||||
</Show>
|
|
||||||
{/* 属性简写显示 */}
|
|
||||||
<Show when={Object.keys(item.attributes).length > 0}>
|
|
||||||
<span class="text-xs text-gray-500 font-mono whitespace-nowrap truncate flex gap-2">
|
|
||||||
<For each={Object.entries(item.attributes)}>
|
|
||||||
{([name, attr]) => (
|
|
||||||
<span
|
|
||||||
class="inline-block cursor-pointer hover:text-blue-600 hover:underline"
|
|
||||||
title={`${name}: ${formatAttributeValue(attr)} - 滚动鼠标滚轮增减`}
|
|
||||||
onWheel={(e) => handleWheel(e, index(), name, attr)}
|
|
||||||
>
|
|
||||||
{formatAttributeShort(name, attr)}{" "}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1 flex-shrink-0 ml-2">
|
|
||||||
<button
|
|
||||||
class="p-1 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded text-xs"
|
|
||||||
title="上移"
|
|
||||||
onClick={() => props.onMoveUp?.(index())}
|
|
||||||
>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="p-1 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded text-xs"
|
|
||||||
title="下移"
|
|
||||||
onClick={() => props.onMoveDown?.(index())}
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="p-1 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded text-xs"
|
|
||||||
title="移除"
|
|
||||||
onClick={() => props.onRemove?.(index())}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* 属性编辑工具提示 */}
|
|
||||||
<Show when={editingItem()}>
|
|
||||||
<AttributeTooltip
|
|
||||||
position={editingItem()!.position}
|
|
||||||
attributes={props.items()[editingItem()!.index]?.attributes || {}}
|
|
||||||
classes={props.items()[editingItem()!.index]?.classes || []}
|
|
||||||
onUpdate={handleUpdateAttribute}
|
|
||||||
onClassesChange={handleClassesChange}
|
|
||||||
onClose={() => setEditingItem(null)}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { MdCommanderCommand } from "../types";
|
|
||||||
|
|
||||||
export const clearCommand: MdCommanderCommand = {
|
|
||||||
command: "clear",
|
|
||||||
description: "清空命令历史",
|
|
||||||
handler: () => ({
|
|
||||||
message: "命令历史已清空",
|
|
||||||
type: "success",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { MdCommanderCommand, MdCommanderCommandMap } from "../types";
|
|
||||||
|
|
||||||
export function setupHelpCommand(commands: MdCommanderCommandMap): MdCommanderCommand {
|
|
||||||
return {
|
|
||||||
command: "help",
|
|
||||||
description: "显示帮助信息或特定命令的帮助",
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: "cmd",
|
|
||||||
description: "要查询的命令名",
|
|
||||||
type: "enum",
|
|
||||||
values: Object.keys(commands).filter((k) => k !== "help"),
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
handler: (args, cmds) => {
|
|
||||||
const cmdName = args.params.cmd;
|
|
||||||
if (cmdName && cmds?.[cmdName]) {
|
|
||||||
return {
|
|
||||||
message: `命令:${cmdName}\n描述:${cmds[cmdName]?.description || "无描述"}`,
|
|
||||||
type: "info",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const cmdList = Object.keys(cmds || {}).filter((k) => k !== "help").join(", ");
|
|
||||||
return {
|
|
||||||
message: `可用命令:${cmdList}`,
|
|
||||||
type: "info",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { setupHelpCommand } from "./help";
|
|
||||||
export { clearCommand } from "./clear";
|
|
||||||
export { rollCommand } from "./roll";
|
|
||||||
export { trackCommand, untrackCommand, listTrackCommand } from "./tracker";
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { MdCommanderCommand } from "../types";
|
|
||||||
import { rollSimple } from "../hooks/useDiceRoller";
|
|
||||||
|
|
||||||
export const rollCommand: MdCommanderCommand = {
|
|
||||||
command: "roll",
|
|
||||||
description: "掷骰子 - 支持骰池、修饰符和组合",
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: "formula",
|
|
||||||
description: "骰子公式,如 3d6+{d4,d8}kh2-5",
|
|
||||||
type: "string",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
handler: (args) => {
|
|
||||||
const formula = args.params.formula || "1d6";
|
|
||||||
const result = rollSimple(formula);
|
|
||||||
return {
|
|
||||||
message: result.text,
|
|
||||||
isHtml: result.isHtml,
|
|
||||||
type: result.text.startsWith("错误") ? "error" : "success",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import type { MdCommanderCommand } from "../types";
|
|
||||||
import { parseEmmet } from "../utils";
|
|
||||||
import { addTrackerItem, removeTrackerItem, getTrackerItems, findTrackerItem } from "../stores";
|
|
||||||
|
|
||||||
export const trackCommand: MdCommanderCommand = {
|
|
||||||
command: "track",
|
|
||||||
description: "添加一个新的追踪项目 - 支持 Emmet 语法:track npc#john.dwarf.warrior[hp=4/4 ac=15]",
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: "emmet",
|
|
||||||
description: "Emmet 格式的追踪项目:tag#class1.class2[attr=value]",
|
|
||||||
type: "string",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
handler: (args, commands) => {
|
|
||||||
const emmet = args.params.emmet;
|
|
||||||
if (!emmet) {
|
|
||||||
return { message: "错误:缺少 Emmet 参数", type: "error" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseEmmet(emmet);
|
|
||||||
|
|
||||||
if (!parsed.tag) {
|
|
||||||
return { message: "错误:缺少 tag", type: "error" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直接添加追踪项目
|
|
||||||
addTrackerItem(parsed);
|
|
||||||
|
|
||||||
const idStr = parsed.id ? ` #${parsed.id}` : "";
|
|
||||||
const classStr = parsed.classes.length > 0 ? `.${parsed.classes.join(".")}` : "";
|
|
||||||
const attrCount = Object.keys(parsed.attributes).length;
|
|
||||||
const attrStr = attrCount > 0 ? `[${attrCount} 个属性]` : "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: `已添加追踪项目:${parsed.tag}${idStr}${classStr}${attrStr}`,
|
|
||||||
type: "success",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const untrackCommand: MdCommanderCommand = {
|
|
||||||
command: "untrack",
|
|
||||||
description: "移除一个追踪项目 - 支持 Emmet 语法:untrack #g2 或 untrack goblin.warrior",
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: "emmet",
|
|
||||||
description: "Emmet 格式:#id 或 tag.class",
|
|
||||||
type: "string",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
handler: (args) => {
|
|
||||||
const emmet = args.params.emmet;
|
|
||||||
if (!emmet) {
|
|
||||||
return { message: "错误:缺少 Emmet 参数", type: "error" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const found = findTrackerItem(emmet);
|
|
||||||
if (!found) {
|
|
||||||
return { message: `未找到匹配的追踪项目:${emmet}`, type: "error" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = removeTrackerItem(emmet);
|
|
||||||
if (!success) {
|
|
||||||
return { message: `移除失败:${emmet}`, type: "error" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { message: `已移除追踪项目:${found.tag}${found.id ? '#' + found.id : ''}`, type: "success" };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listTrackCommand: MdCommanderCommand = {
|
|
||||||
command: "list",
|
|
||||||
description: "列出所有追踪项目",
|
|
||||||
handler: (args, commands) => {
|
|
||||||
const items = getTrackerItems();
|
|
||||||
if (items.length === 0) {
|
|
||||||
return { message: "暂无追踪项目", type: "info" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = items.map((i) => `${i.tag}${i.id ? "#" + i.id : ""}${i.classes.length > 0 ? "." + i.classes.join(".") : ""}`).join("\n");
|
|
||||||
return { message: `追踪项目:\n${list}`, type: "info" };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import type { MdCommanderCommandMap, CompletionItem } from "../types";
|
|
||||||
|
|
||||||
export interface ParsedCommand {
|
|
||||||
command?: string;
|
|
||||||
params: Record<string, string>;
|
|
||||||
options: Record<string, string>;
|
|
||||||
incompleteParam?: { index: number; value: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析命令行输入
|
|
||||||
*/
|
|
||||||
export function parseInput(input: string, commands?: MdCommanderCommandMap): ParsedCommand {
|
|
||||||
const result: ParsedCommand = { params: {}, options: {} };
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed) return result;
|
|
||||||
|
|
||||||
const parts = trimmed.split(/\s+/);
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
// 获取命令
|
|
||||||
if (parts[0] && !parts[0].startsWith("-")) {
|
|
||||||
result.command = parts[0];
|
|
||||||
i = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取命令的参数定义
|
|
||||||
const cmd = result.command ? commands?.[result.command] : undefined;
|
|
||||||
const paramDefs = cmd?.parameters || [];
|
|
||||||
let paramIndex = 0;
|
|
||||||
|
|
||||||
// 解析参数和选项
|
|
||||||
while (i < parts.length) {
|
|
||||||
const part = parts[i];
|
|
||||||
|
|
||||||
if (part.startsWith("--")) {
|
|
||||||
const eqIndex = part.indexOf("=");
|
|
||||||
if (eqIndex !== -1) {
|
|
||||||
result.options[part.slice(2, eqIndex)] = part.slice(eqIndex + 1);
|
|
||||||
} else {
|
|
||||||
const key = part.slice(2);
|
|
||||||
if (i + 1 < parts.length && !parts[i + 1].startsWith("-")) {
|
|
||||||
result.options[key] = parts[i + 1];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (part.startsWith("-") && part.length === 2) {
|
|
||||||
const key = part.slice(1);
|
|
||||||
if (i + 1 < parts.length && !parts[i + 1].startsWith("-")) {
|
|
||||||
result.options[key] = parts[i + 1];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
} else if (paramIndex < paramDefs.length) {
|
|
||||||
result.params[paramDefs[paramIndex].name] = part;
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有未完成的位置参数
|
|
||||||
if (paramIndex < paramDefs.length) {
|
|
||||||
const lastPart = parts[parts.length - 1];
|
|
||||||
if (lastPart && !lastPart.startsWith("-") && parts.length > 1) {
|
|
||||||
result.incompleteParam = { index: paramIndex, value: lastPart };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取自动补全建议
|
|
||||||
*/
|
|
||||||
export function getCompletions(input: string, commands: MdCommanderCommandMap): CompletionItem[] {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
|
|
||||||
// 空输入时返回所有命令
|
|
||||||
if (!trimmed || /^\s*$/.test(trimmed)) {
|
|
||||||
return Object.values(commands).map((cmd) => ({
|
|
||||||
label: cmd.command,
|
|
||||||
type: "command",
|
|
||||||
description: cmd.description,
|
|
||||||
insertText: cmd.command,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseInput(trimmed, commands);
|
|
||||||
|
|
||||||
// 命令补全
|
|
||||||
if (!parsed.command || !commands[parsed.command]) {
|
|
||||||
return Object.values(commands)
|
|
||||||
.filter((cmd) => cmd.command.startsWith(parsed.command || ""))
|
|
||||||
.map((cmd) => ({
|
|
||||||
label: cmd.command,
|
|
||||||
type: "command",
|
|
||||||
description: cmd.description,
|
|
||||||
insertText: cmd.command,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const cmd = commands[parsed.command];
|
|
||||||
if (!cmd) return [];
|
|
||||||
|
|
||||||
const paramDefs = cmd.parameters || [];
|
|
||||||
const usedParams = Object.keys(parsed.params);
|
|
||||||
|
|
||||||
// 判断是否正在输入最后一个参数
|
|
||||||
const isTypingLastParam = paramDefs.length === usedParams.length &&
|
|
||||||
trimmed.length > 0 &&
|
|
||||||
!trimmed.endsWith(" ") &&
|
|
||||||
!trimmed.split(/\s+/)[trimmed.split(/\s+/).length - 1].startsWith("-");
|
|
||||||
|
|
||||||
// 参数补全
|
|
||||||
if (paramDefs.length > usedParams.length || parsed.incompleteParam || isTypingLastParam) {
|
|
||||||
let paramIndex = parsed.incompleteParam?.index ?? (isTypingLastParam ? paramDefs.length - 1 : usedParams.length);
|
|
||||||
const paramDef = paramDefs[paramIndex];
|
|
||||||
if (!paramDef) return [];
|
|
||||||
|
|
||||||
let currentValue = parsed.incompleteParam?.value || "";
|
|
||||||
if (isTypingLastParam && !parsed.incompleteParam) {
|
|
||||||
const parts = trimmed.split(/\s+/);
|
|
||||||
currentValue = parts[parts.length - 1] || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模板补全
|
|
||||||
if (paramDef.templates) {
|
|
||||||
return paramDef.templates
|
|
||||||
.filter((t) => t.label.toLowerCase().includes(currentValue.toLowerCase()))
|
|
||||||
.map((t) => ({
|
|
||||||
label: t.label,
|
|
||||||
type: "value",
|
|
||||||
description: t.description,
|
|
||||||
insertText: t.insertText,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 枚举补全
|
|
||||||
if (paramDef.type === "enum" && paramDef.values) {
|
|
||||||
return paramDef.values
|
|
||||||
.filter((v) => v.startsWith(currentValue))
|
|
||||||
.map((v) => ({
|
|
||||||
label: v,
|
|
||||||
type: "value",
|
|
||||||
description: paramDef.description,
|
|
||||||
insertText: v,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他类型显示提示
|
|
||||||
return [{
|
|
||||||
label: `<${paramDef.name}>`,
|
|
||||||
type: "value",
|
|
||||||
description: `${paramDef.type}${paramDef.required !== false ? " (必填)" : ""}: ${paramDef.description || ""}`,
|
|
||||||
insertText: "",
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选项补全
|
|
||||||
if (!cmd.options) return [];
|
|
||||||
|
|
||||||
const usedOptions = Object.keys(parsed.options);
|
|
||||||
return Object.values(cmd.options)
|
|
||||||
.filter((opt) => !usedOptions.includes(opt.option))
|
|
||||||
.map((opt) => ({
|
|
||||||
label: `--${opt.option}`,
|
|
||||||
type: "option",
|
|
||||||
description: opt.description,
|
|
||||||
insertText: `--${opt.option}=${opt.type === "boolean" ? "" : ""}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据结果类型获取 CSS 类名
|
|
||||||
*/
|
|
||||||
export function getResultClass(type?: "success" | "error" | "warning" | "info"): string {
|
|
||||||
switch (type) {
|
|
||||||
case "success": return "text-green-600";
|
|
||||||
case "error": return "text-red-600";
|
|
||||||
case "warning": return "text-yellow-600";
|
|
||||||
default: return "text-blue-600";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export type {
|
|
||||||
DieResult,
|
|
||||||
DicePool,
|
|
||||||
RollResult,
|
|
||||||
DicePoolResult,
|
|
||||||
Token,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export { tokenize, parse } from "./parser";
|
|
||||||
export { rollDie, rollDicePool, applyModifier, roll } from "./roller";
|
|
||||||
export { testDiceEngine } from "./test";
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { tokenize, parse } from "./parser";
|
|
||||||
import { rollDicePool, roll } from "./roller";
|
|
||||||
import type { DicePool } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 骰子引擎测试
|
|
||||||
* 在浏览器控制台运行:window.testDiceEngine()
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function testDiceEngine() {
|
|
||||||
console.log("=== 骰子引擎测试 ===\n");
|
|
||||||
|
|
||||||
// 测试用例
|
|
||||||
const testCases: Array<{
|
|
||||||
name: string;
|
|
||||||
formula: string;
|
|
||||||
expected: {
|
|
||||||
hasPools: number;
|
|
||||||
hasModifier?: boolean;
|
|
||||||
};
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
name: "标准骰子 3d6",
|
|
||||||
formula: "3d6",
|
|
||||||
expected: { hasPools: 1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "单个骰子 d20",
|
|
||||||
formula: "d20",
|
|
||||||
expected: { hasPools: 1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "骰池字面量 {d4,d8}",
|
|
||||||
formula: "{d4,d8}",
|
|
||||||
expected: { hasPools: 1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "带修饰符 3d6kh2",
|
|
||||||
formula: "3d6kh2",
|
|
||||||
expected: { hasPools: 1, hasModifier: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "带修饰符 4d6dl1",
|
|
||||||
formula: "4d6dl1",
|
|
||||||
expected: { hasPools: 1, hasModifier: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "组合骰池 3d6+5",
|
|
||||||
formula: "3d6+5",
|
|
||||||
expected: { hasPools: 2 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "负数组合 3d6-2",
|
|
||||||
formula: "3d6-2",
|
|
||||||
expected: { hasPools: 2 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "复杂公式 3d6+{d4,d8}kh2-5",
|
|
||||||
formula: "3d6+{d4,d8}kh2-5",
|
|
||||||
expected: { hasPools: 4, hasModifier: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "多个骰池 {d6,d8}+{d10,d12}",
|
|
||||||
formula: "{d6,d8}+{d10,d12}",
|
|
||||||
expected: { hasPools: 2 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Keep Lowest 5d6kl3",
|
|
||||||
formula: "5d6kl3",
|
|
||||||
expected: { hasPools: 1, hasModifier: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Drop Highest 4d6dh1",
|
|
||||||
formula: "4d6dh1",
|
|
||||||
expected: { hasPools: 1, hasModifier: true },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let passed = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
for (const tc of testCases) {
|
|
||||||
try {
|
|
||||||
// 测试词法分析
|
|
||||||
const tokens = tokenize(tc.formula);
|
|
||||||
console.log(`\n公式:${tc.formula}`);
|
|
||||||
console.log(` 令牌:${tokens.length} 个`);
|
|
||||||
|
|
||||||
// 测试语法分析
|
|
||||||
const pools = parse(tokens);
|
|
||||||
console.log(` 骰池:${pools.length} 个`);
|
|
||||||
|
|
||||||
// 测试掷骰
|
|
||||||
const result = roll(tc.formula);
|
|
||||||
console.log(` 结果:${result.total}`);
|
|
||||||
console.log(` 详情:${result.detail}`);
|
|
||||||
|
|
||||||
// 验证
|
|
||||||
if (pools.length >= tc.expected.hasPools) {
|
|
||||||
console.log(` ✓ 通过`);
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log(` ✗ 失败:期望至少 ${tc.expected.hasPools} 个骰池,实际 ${pools.length} 个`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tc.expected.hasModifier) {
|
|
||||||
const hasMod = pools.some((p) => p.modifier !== undefined);
|
|
||||||
if (hasMod) {
|
|
||||||
console.log(` ✓ 修饰符正确`);
|
|
||||||
} else {
|
|
||||||
console.log(` ✗ 修饰符缺失`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(` ✗ 错误:${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n=== 测试完成:${passed} 通过,${failed} 失败 ===`);
|
|
||||||
return { passed, failed };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在浏览器环境下暴露到全局
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
(window as any).testDiceEngine = testDiceEngine;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export { useCommander } from "./useCommander";
|
|
||||||
export type { UseCommanderReturn } from "./useCommander";
|
|
||||||
export { rollSimple, rollFormula } from "./useDiceRoller";
|
|
||||||
export type { DiceRollerResult } from "./useDiceRoller";
|
|
||||||
export { parseInput, getCompletions, getResultClass } from "./completions";
|
|
||||||
export type { ParsedCommand } from "./completions";
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { createSignal } from "solid-js";
|
|
||||||
import type {
|
|
||||||
CommanderEntry,
|
|
||||||
CompletionItem,
|
|
||||||
TrackerItem,
|
|
||||||
TrackerAttribute,
|
|
||||||
TrackerViewMode,
|
|
||||||
MdCommanderCommandMap,
|
|
||||||
TrackerCommand,
|
|
||||||
} from "../types";
|
|
||||||
import { parseInput, getCompletions } from "./completions";
|
|
||||||
import {
|
|
||||||
addTrackerItem,
|
|
||||||
getTrackerHistory,
|
|
||||||
getTrackerItems,
|
|
||||||
moveTrackerItem,
|
|
||||||
removeTrackerClass,
|
|
||||||
removeTrackerItem,
|
|
||||||
updateTrackerAttribute,
|
|
||||||
updateTrackerClasses,
|
|
||||||
} from "../stores";
|
|
||||||
import { addEntry, getEntries, clearEntries } from "../stores/entriesStore";
|
|
||||||
|
|
||||||
// ==================== Commander Hook ====================
|
|
||||||
|
|
||||||
export interface UseCommanderReturn {
|
|
||||||
inputValue: () => string;
|
|
||||||
entries: () => CommanderEntry[];
|
|
||||||
showCompletions: () => boolean;
|
|
||||||
completions: () => CompletionItem[];
|
|
||||||
selectedCompletion: () => number;
|
|
||||||
isFocused: () => boolean;
|
|
||||||
setInputValue: (v: string) => void;
|
|
||||||
setShowCompletions: (v: boolean) => void;
|
|
||||||
setSelectedCompletion: (v: number | ((prev: number) => number)) => void;
|
|
||||||
setIsFocused: (v: boolean) => void;
|
|
||||||
handleCommand: () => void;
|
|
||||||
updateCompletions: () => void;
|
|
||||||
acceptCompletion: () => void;
|
|
||||||
commands: () => MdCommanderCommandMap;
|
|
||||||
setCommands: (cmds: MdCommanderCommandMap) => void;
|
|
||||||
historyIndex: () => number;
|
|
||||||
setHistoryIndex: (v: number) => void;
|
|
||||||
commandHistory: () => string[];
|
|
||||||
navigateHistory: (direction: "up" | "down") => void;
|
|
||||||
|
|
||||||
// Tracker 相关
|
|
||||||
viewMode: () => TrackerViewMode;
|
|
||||||
setViewMode: (mode: TrackerViewMode) => void;
|
|
||||||
trackerItems: () => TrackerItem[];
|
|
||||||
trackerHistory: () => TrackerCommand[];
|
|
||||||
addTrackerItem: (item: Omit<TrackerItem, "id">) => TrackerItem;
|
|
||||||
removeTrackerItem: (emmet: string) => boolean;
|
|
||||||
removeTrackerItemByIndex: (index: number) => void;
|
|
||||||
updateTrackerAttribute: (emmet: string, attrName: string, attr: TrackerAttribute) => boolean;
|
|
||||||
updateTrackerAttributeByIndex: (index: number, attrName: string, attr: TrackerAttribute) => void;
|
|
||||||
updateTrackerClassesByIndex: (index: number, classes: string[]) => void;
|
|
||||||
moveTrackerItem: (emmet: string, direction: "up" | "down") => boolean;
|
|
||||||
moveTrackerItemByIndex: (index: number, direction: "up" | "down") => void;
|
|
||||||
removeTrackerItemClass: (emmet: string, className: string) => boolean;
|
|
||||||
removeTrackerItemClassByIndex: (index: number, className: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCommander(initialCommands?: MdCommanderCommandMap): UseCommanderReturn {
|
|
||||||
const [inputValue, setInputValue] = createSignal("");
|
|
||||||
const [showCompletions, setShowCompletions] = createSignal(false);
|
|
||||||
const [completions, setCompletions] = createSignal<CompletionItem[]>([]);
|
|
||||||
const [selectedCompletion, setSelectedCompletionState] = createSignal(0);
|
|
||||||
const [isFocused, setIsFocused] = createSignal(false);
|
|
||||||
const [commandHistory, setCommandHistory] = createSignal<string[]>([]);
|
|
||||||
const [historyIndex, setHistoryIndex] = createSignal(-1);
|
|
||||||
const [viewMode, setViewMode] = createSignal<TrackerViewMode>("history");
|
|
||||||
const [commands, setCommands] = createSignal<MdCommanderCommandMap>(initialCommands || {});
|
|
||||||
|
|
||||||
// 从 store 获取 entries
|
|
||||||
const entries = getEntries;
|
|
||||||
|
|
||||||
// ==================== 命令执行 ====================
|
|
||||||
|
|
||||||
const handleCommand = () => {
|
|
||||||
const input = inputValue().trim();
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
const cmds = commands();
|
|
||||||
const parsed = parseInput(input, cmds);
|
|
||||||
const commandName = parsed.command;
|
|
||||||
const cmd = cmds[commandName!];
|
|
||||||
|
|
||||||
let result: { message: string; type?: "success" | "error" | "warning" | "info"; isHtml?: boolean };
|
|
||||||
|
|
||||||
if (!cmd) {
|
|
||||||
result = { message: `未知命令:${commandName}`, type: "error" };
|
|
||||||
} else if (cmd.handler) {
|
|
||||||
try {
|
|
||||||
result = cmd.handler({ params: parsed.params, options: parsed.options }, cmds);
|
|
||||||
} catch (e) {
|
|
||||||
result = {
|
|
||||||
message: `执行错误:${e instanceof Error ? e.message : String(e)}`,
|
|
||||||
type: "error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result = { message: `命令 ${commandName} 已执行(无处理器)`, type: "info" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEntry: CommanderEntry = {
|
|
||||||
id: Date.now().toString() + Math.random().toString(36).slice(2),
|
|
||||||
command: input,
|
|
||||||
args: parsed.options,
|
|
||||||
result,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 使用 store 添加记录
|
|
||||||
addEntry(newEntry);
|
|
||||||
setCommandHistory((prev) => [...prev, input]);
|
|
||||||
setHistoryIndex(-1);
|
|
||||||
setInputValue("");
|
|
||||||
setShowCompletions(false);
|
|
||||||
|
|
||||||
if (commandName === "clear") {
|
|
||||||
clearEntries();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== 自动补全 ====================
|
|
||||||
|
|
||||||
const updateCompletions = () => {
|
|
||||||
const comps = getCompletions(inputValue(), commands());
|
|
||||||
setCompletions(comps);
|
|
||||||
setShowCompletions(comps.length > 0 && isFocused());
|
|
||||||
setSelectedCompletionState(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setSelectedCompletion = (v: number | ((prev: number) => number)) => {
|
|
||||||
const maxIdx = completions().length - 1;
|
|
||||||
if (typeof v === "function") {
|
|
||||||
setSelectedCompletionState((prev) => {
|
|
||||||
const next = v(prev);
|
|
||||||
return Math.max(0, Math.min(next, maxIdx));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setSelectedCompletionState(Math.max(0, Math.min(v, maxIdx)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const acceptCompletion = () => {
|
|
||||||
const idx = selectedCompletion();
|
|
||||||
const comps = completions();
|
|
||||||
const comp = comps[Math.min(idx, comps.length - 1)];
|
|
||||||
if (!comp) return;
|
|
||||||
|
|
||||||
const input = inputValue();
|
|
||||||
const parsed = parseInput(input, commands());
|
|
||||||
let newValue: string;
|
|
||||||
|
|
||||||
if (comp.type === "command") {
|
|
||||||
newValue = comp.insertText + " ";
|
|
||||||
} else if (comp.type === "option") {
|
|
||||||
const existingOptions = Object.entries(parsed.options)
|
|
||||||
.map(([k, v]) => `--${k}=${v}`)
|
|
||||||
.join(" ");
|
|
||||||
newValue = `${parsed.command || ""} ${existingOptions}${existingOptions ? " " : ""}${comp.insertText}`;
|
|
||||||
} else if (comp.type === "value") {
|
|
||||||
const cmd = parsed.command ? commands()[parsed.command] : null;
|
|
||||||
const paramDefs = cmd?.parameters || [];
|
|
||||||
const usedParams = Object.keys(parsed.params);
|
|
||||||
const isTypingLastParam = paramDefs.length === usedParams.length && !input.endsWith(" ");
|
|
||||||
|
|
||||||
if (isTypingLastParam) {
|
|
||||||
const parts = input.trim().split(/\s+/);
|
|
||||||
parts.pop();
|
|
||||||
newValue = `${parsed.command || ""}${parts.length > 1 ? " " + parts.slice(1).join(" ") : ""} ${comp.insertText}`;
|
|
||||||
} else if (paramDefs.length > usedParams.length) {
|
|
||||||
const existingParams = Object.values(parsed.params).join(" ");
|
|
||||||
newValue = `${parsed.command || ""} ${existingParams}${existingParams ? " " : ""}${comp.insertText}`;
|
|
||||||
} else {
|
|
||||||
newValue = input;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newValue = input;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInputValue(newValue.trim());
|
|
||||||
setShowCompletions(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== 命令历史 ====================
|
|
||||||
|
|
||||||
const navigateHistory = (direction: "up" | "down") => {
|
|
||||||
const history = commandHistory();
|
|
||||||
if (history.length === 0) return;
|
|
||||||
|
|
||||||
let newIndex = historyIndex();
|
|
||||||
if (direction === "up") {
|
|
||||||
if (newIndex < history.length - 1) newIndex++;
|
|
||||||
} else {
|
|
||||||
if (newIndex > 0) {
|
|
||||||
newIndex--;
|
|
||||||
} else {
|
|
||||||
setInputValue("");
|
|
||||||
setHistoryIndex(-1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setHistoryIndex(newIndex);
|
|
||||||
setInputValue(history[history.length - 1 - newIndex]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Tracker 操作 ====================
|
|
||||||
|
|
||||||
const getEmmetFromIndex = (index: number): string | null => {
|
|
||||||
const items = getTrackerItems();
|
|
||||||
if (index < 0 || index >= items.length) return null;
|
|
||||||
const item = items[index];
|
|
||||||
return `${item.tag}${item.id ? "#" + item.id : ""}${item.classes.length > 0 ? "." + item.classes.join(".") : ""}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTrackerItemByIndex = (index: number) => {
|
|
||||||
const emmet = getEmmetFromIndex(index);
|
|
||||||
if (emmet) removeTrackerItem(emmet);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTrackerAttributeByIndex = (index: number, attrName: string, attr: TrackerAttribute) => {
|
|
||||||
const emmet = getEmmetFromIndex(index);
|
|
||||||
if (emmet) updateTrackerAttribute(emmet, attrName, attr);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTrackerClassesByIndex = (index: number, classes: string[]) => {
|
|
||||||
const emmet = getEmmetFromIndex(index);
|
|
||||||
if (emmet) updateTrackerClasses(emmet, classes);
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveTrackerItemByIndex = (index: number, direction: "up" | "down") => {
|
|
||||||
const emmet = getEmmetFromIndex(index);
|
|
||||||
if (emmet) moveTrackerItem(emmet, direction);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTrackerItemClassByIndex = (index: number, className: string) => {
|
|
||||||
const emmet = getEmmetFromIndex(index);
|
|
||||||
if (emmet) removeTrackerClass(emmet, className);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
inputValue,
|
|
||||||
entries,
|
|
||||||
showCompletions,
|
|
||||||
completions,
|
|
||||||
selectedCompletion,
|
|
||||||
isFocused,
|
|
||||||
setInputValue,
|
|
||||||
setShowCompletions,
|
|
||||||
setSelectedCompletion,
|
|
||||||
setIsFocused,
|
|
||||||
handleCommand,
|
|
||||||
updateCompletions,
|
|
||||||
acceptCompletion,
|
|
||||||
commands,
|
|
||||||
setCommands,
|
|
||||||
historyIndex,
|
|
||||||
setHistoryIndex,
|
|
||||||
commandHistory,
|
|
||||||
navigateHistory,
|
|
||||||
viewMode,
|
|
||||||
setViewMode,
|
|
||||||
trackerItems: getTrackerItems,
|
|
||||||
trackerHistory: getTrackerHistory,
|
|
||||||
addTrackerItem,
|
|
||||||
removeTrackerItem,
|
|
||||||
removeTrackerItemByIndex,
|
|
||||||
updateTrackerAttribute,
|
|
||||||
updateTrackerAttributeByIndex,
|
|
||||||
updateTrackerClassesByIndex,
|
|
||||||
moveTrackerItem,
|
|
||||||
moveTrackerItemByIndex,
|
|
||||||
removeTrackerItemClass: removeTrackerClass,
|
|
||||||
removeTrackerItemClassByIndex,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { customElement, noShadowDOM } from "solid-element";
|
|
||||||
import { onMount, onCleanup, createResource } from "solid-js";
|
|
||||||
import { useCommander } from "./hooks";
|
|
||||||
import { CommanderInput } from "./CommanderInput";
|
|
||||||
import { CommanderEntries } from "./CommanderEntries";
|
|
||||||
import { TrackerView } from "./TrackerView";
|
|
||||||
import { TabBar } from "./TabBar";
|
|
||||||
import type { MdCommanderProps } from "./types";
|
|
||||||
import { loadElementSrc } from "../utils/path";
|
|
||||||
import { initializeCommands, loadCommandTemplatesFromCSV, getCommands, getCommandsLoading, getCommandsError } from "./stores";
|
|
||||||
|
|
||||||
customElement<MdCommanderProps>(
|
|
||||||
"md-commander",
|
|
||||||
{ placeholder: "", class: "", height: "", commandTemplates: "" },
|
|
||||||
(props, { element }) => {
|
|
||||||
noShadowDOM();
|
|
||||||
const { articlePath, rawSrc } = loadElementSrc(element);
|
|
||||||
|
|
||||||
// 初始化命令
|
|
||||||
const commands = initializeCommands(props.commands);
|
|
||||||
const commander = useCommander(commands);
|
|
||||||
|
|
||||||
// 使用 createResource 加载 CSV 模板
|
|
||||||
const [templateData] = createResource(
|
|
||||||
() => (rawSrc ? { path: rawSrc, articlePath } : null),
|
|
||||||
async (paths) => {
|
|
||||||
await loadCommandTemplatesFromCSV(paths.path, paths.articlePath);
|
|
||||||
return getCommands();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当模板加载完成后更新 commander 中的命令
|
|
||||||
createResource(
|
|
||||||
() => templateData(),
|
|
||||||
(loadedCommands) => {
|
|
||||||
if (loadedCommands) {
|
|
||||||
commander.setCommands(loadedCommands);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (commander.showCompletions() && commander.completions().length > 0) {
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.setSelectedCompletion(
|
|
||||||
(prev) => (prev + 1) % commander.completions().length,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.setSelectedCompletion(
|
|
||||||
(prev) => (prev - 1 + commander.completions().length) % commander.completions().length,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Tab") {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.acceptCompletion();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
commander.setShowCompletions(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === "ArrowUp" && !commander.showCompletions()) {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.navigateHistory("up");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowDown" && !commander.showCompletions()) {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.navigateHistory("down");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.handleCommand();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const heightStyle = () => props.height || "400px";
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (!element?.contains(e.target as Node)) {
|
|
||||||
commander.setShowCompletions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("click", handleClickOutside);
|
|
||||||
onCleanup(() => document.removeEventListener("click", handleClickOutside));
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={`w-full flex flex-col border border-gray-300 rounded-lg overflow-hidden mb-4 ${props.class || ""}`}
|
|
||||||
style={{ height: heightStyle() }}
|
|
||||||
>
|
|
||||||
<TabBar mode={commander.viewMode} onModeChange={commander.setViewMode} />
|
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col lg:flex-row">
|
|
||||||
{/* 历史视图 */}
|
|
||||||
<div class={`flex-1 flex-col min-h-0 lg:flex lg:w-1/2 lg:border-r lg:border-gray-300 ${commander.viewMode() !== "history" ? "hidden" : ""}`}>
|
|
||||||
<CommanderEntries
|
|
||||||
entries={commander.entries}
|
|
||||||
loading={getCommandsLoading()}
|
|
||||||
error={getCommandsError()}
|
|
||||||
onCommandClick={(cmd) => commander.setInputValue(cmd)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 追踪视图 */}
|
|
||||||
<div class={`flex-1 flex-col min-h-0 lg:flex lg:w-1/2 lg:border-r lg:border-gray-300 ${commander.viewMode() !== "tracker" ? "hidden" : ""}`}>
|
|
||||||
<TrackerView
|
|
||||||
items={commander.trackerItems}
|
|
||||||
onEditAttribute={(index, attrName, attr) =>
|
|
||||||
commander.updateTrackerAttributeByIndex(index, attrName, attr)
|
|
||||||
}
|
|
||||||
onClassesChange={(index, classes) =>
|
|
||||||
commander.updateTrackerClassesByIndex(index, classes)
|
|
||||||
}
|
|
||||||
onRemoveClass={(index, className) =>
|
|
||||||
commander.removeTrackerItemClassByIndex(index, className)
|
|
||||||
}
|
|
||||||
onMoveUp={(index) => commander.moveTrackerItemByIndex(index, "up")}
|
|
||||||
onMoveDown={(index) => commander.moveTrackerItemByIndex(index, "down")}
|
|
||||||
onRemove={(index) => commander.removeTrackerItemByIndex(index)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative border-t border-gray-300">
|
|
||||||
<CommanderInput
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
inputValue={commander.inputValue}
|
|
||||||
onInput={(e) => {
|
|
||||||
commander.setInputValue((e.target as HTMLInputElement).value);
|
|
||||||
commander.updateCompletions();
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onFocus={() => {
|
|
||||||
commander.setIsFocused(true);
|
|
||||||
commander.updateCompletions();
|
|
||||||
}}
|
|
||||||
onBlur={() => commander.setIsFocused(false)}
|
|
||||||
onSubmit={commander.handleCommand}
|
|
||||||
showCompletions={commander.showCompletions}
|
|
||||||
completions={commander.completions}
|
|
||||||
selectedCompletion={commander.selectedCompletion}
|
|
||||||
onSelectCompletion={commander.setSelectedCompletion}
|
|
||||||
onAcceptCompletion={commander.acceptCompletion}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import { createStore } from "solid-js/store";
|
|
||||||
import type { MdCommanderCommand, MdCommanderCommandMap } from "../types";
|
|
||||||
import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands";
|
|
||||||
import {resolvePath} from "../../utils/path";
|
|
||||||
import {loadCSVFromPath} from "../../utils/csv-loader";
|
|
||||||
|
|
||||||
const defaultCommands: MdCommanderCommandMap = {
|
|
||||||
help: setupHelpCommand({}),
|
|
||||||
clear: clearCommand,
|
|
||||||
roll: rollCommand,
|
|
||||||
track: trackCommand,
|
|
||||||
untrack: untrackCommand,
|
|
||||||
list: listTrackCommand,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CommandsStoreState {
|
|
||||||
commands: MdCommanderCommandMap;
|
|
||||||
initialized: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [commandsStore, setCommandsStore] = createStore<CommandsStoreState>({
|
|
||||||
commands: { ...defaultCommands },
|
|
||||||
initialized: false,
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化命令(包括 help 命令的动态更新)
|
|
||||||
*/
|
|
||||||
export function initializeCommands(customCommands?: MdCommanderCommandMap): MdCommanderCommandMap {
|
|
||||||
const commands = { ...defaultCommands, ...customCommands };
|
|
||||||
// 更新 help 命令的命令列表
|
|
||||||
commands.help = setupHelpCommand(commands);
|
|
||||||
return commands;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册命令到 store
|
|
||||||
*/
|
|
||||||
export function registerCommands(customCommands?: MdCommanderCommandMap): void {
|
|
||||||
const commands = initializeCommands(customCommands);
|
|
||||||
setCommandsStore({ commands, initialized: true, loading: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 store 获取命令
|
|
||||||
*/
|
|
||||||
export function getCommands(): MdCommanderCommandMap {
|
|
||||||
return commandsStore.commands;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查命令是否已初始化
|
|
||||||
*/
|
|
||||||
export function isCommandsInitialized(): boolean {
|
|
||||||
return commandsStore.initialized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取单个命令
|
|
||||||
*/
|
|
||||||
export function getCommand(name: string): MdCommanderCommand | undefined {
|
|
||||||
return commandsStore.commands[name];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取加载状态
|
|
||||||
*/
|
|
||||||
export function getCommandsLoading(): boolean {
|
|
||||||
return commandsStore.loading;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取错误信息
|
|
||||||
*/
|
|
||||||
export function getCommandsError(): string | undefined {
|
|
||||||
return commandsStore.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置加载状态
|
|
||||||
*/
|
|
||||||
export function setCommandsLoading(loading: boolean): void {
|
|
||||||
setCommandsStore("loading", loading);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置错误信息
|
|
||||||
*/
|
|
||||||
export function setCommandsError(error?: string): void {
|
|
||||||
setCommandsStore("error", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新命令
|
|
||||||
*/
|
|
||||||
export function updateCommands(updater: (prev: MdCommanderCommandMap) => MdCommanderCommandMap): void {
|
|
||||||
setCommandsStore("commands", (prev) => updater(prev));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 CSV 文件加载命令模板并更新命令定义
|
|
||||||
*/
|
|
||||||
export async function loadCommandTemplatesFromCSV(
|
|
||||||
path: string,
|
|
||||||
articlePath: string
|
|
||||||
): Promise<void> {
|
|
||||||
setCommandsLoading(true);
|
|
||||||
setCommandsError(undefined);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const csv = await loadCSVFromPath<CommandTemplateRow>(resolvePath(articlePath, path));
|
|
||||||
|
|
||||||
// 按命令分组模板
|
|
||||||
const templatesByCommand = new Map<string, CommandTemplateRow[]>();
|
|
||||||
for (const row of csv) {
|
|
||||||
if (!row.command || !row.label || !row.insertedText) continue;
|
|
||||||
|
|
||||||
if (!templatesByCommand.has(row.command)) {
|
|
||||||
templatesByCommand.set(row.command, []);
|
|
||||||
}
|
|
||||||
templatesByCommand.get(row.command)!.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为每个命令添加模板
|
|
||||||
updateCommands((prev) => {
|
|
||||||
const updated = { ...prev };
|
|
||||||
for (const [commandName, rows] of templatesByCommand.entries()) {
|
|
||||||
const cmd = updated[commandName];
|
|
||||||
if (!cmd || !cmd.parameters) continue;
|
|
||||||
|
|
||||||
const templates = rows.map((row) => ({
|
|
||||||
label: row.label,
|
|
||||||
description: row.description || "",
|
|
||||||
insertText: row.insertedText,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 为每个参数添加模板
|
|
||||||
updated[commandName] = {
|
|
||||||
...cmd,
|
|
||||||
parameters: cmd.parameters.map((param) => ({
|
|
||||||
...param,
|
|
||||||
templates: param.templates ? [...param.templates, ...templates] : templates,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
setCommandsStore("initialized", true);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
setCommandsError(`加载命令模板失败:${errorMessage}`);
|
|
||||||
console.warn(`Error loading command templates from ${path}:`, error);
|
|
||||||
} finally {
|
|
||||||
setCommandsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandTemplateRow {
|
|
||||||
command: string;
|
|
||||||
parameter: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
insertedText: string;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { createStore } from "solid-js/store";
|
|
||||||
import type { CommanderEntry } from "../types";
|
|
||||||
|
|
||||||
export interface EntriesStore {
|
|
||||||
entries: CommanderEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const [entriesStore, setEntriesStore] = createStore<EntriesStore>({
|
|
||||||
entries: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加命令执行记录
|
|
||||||
*/
|
|
||||||
export function addEntry(entry: CommanderEntry): void {
|
|
||||||
setEntriesStore("entries", (prev) => [...prev, entry]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空所有记录
|
|
||||||
*/
|
|
||||||
export function clearEntries(): void {
|
|
||||||
setEntriesStore("entries", []);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有记录
|
|
||||||
*/
|
|
||||||
export function getEntries(): CommanderEntry[] {
|
|
||||||
return entriesStore.entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量设置记录
|
|
||||||
*/
|
|
||||||
export function setEntries(entries: CommanderEntry[]): void {
|
|
||||||
setEntriesStore("entries", entries);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
export {
|
|
||||||
addTrackerItem,
|
|
||||||
removeTrackerItem,
|
|
||||||
updateTrackerAttribute,
|
|
||||||
updateTrackerClasses,
|
|
||||||
moveTrackerItem,
|
|
||||||
removeTrackerClass,
|
|
||||||
getTrackerItems,
|
|
||||||
getTrackerHistory,
|
|
||||||
clearTrackerHistory,
|
|
||||||
findTrackerIndex,
|
|
||||||
findTrackerItem,
|
|
||||||
} from "./trackerStore";
|
|
||||||
export type { TrackerStore } from "./trackerStore";
|
|
||||||
|
|
||||||
export {
|
|
||||||
initializeCommands,
|
|
||||||
registerCommands,
|
|
||||||
getCommands,
|
|
||||||
isCommandsInitialized,
|
|
||||||
getCommand,
|
|
||||||
loadCommandTemplatesFromCSV,
|
|
||||||
getCommandsLoading,
|
|
||||||
getCommandsError,
|
|
||||||
setCommandsLoading,
|
|
||||||
setCommandsError,
|
|
||||||
updateCommands,
|
|
||||||
} from "./commandsStore";
|
|
||||||
export type { CommandsStoreState } from "./commandsStore";
|
|
||||||
|
|
||||||
export {
|
|
||||||
addEntry,
|
|
||||||
clearEntries,
|
|
||||||
getEntries,
|
|
||||||
setEntries,
|
|
||||||
} from "./entriesStore";
|
|
||||||
export type { EntriesStore } from "./entriesStore";
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import { createStore } from "solid-js/store";
|
|
||||||
import type { TrackerItem, TrackerAttribute, TrackerCommand } from "../types";
|
|
||||||
import {parseEmmet} from "../utils";
|
|
||||||
|
|
||||||
export interface TrackerStore {
|
|
||||||
items: TrackerItem[];
|
|
||||||
history: TrackerCommand[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const [tracker, setTracker] = createStore<TrackerStore>({
|
|
||||||
items: [],
|
|
||||||
history: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据 Emmet 语法查找匹配的项目
|
|
||||||
* 支持:
|
|
||||||
* - #id - 匹配 id
|
|
||||||
* - tag - 匹配 tag
|
|
||||||
* - tag.class - 匹配 tag 和 class
|
|
||||||
* - .class - 匹配 class
|
|
||||||
* - [attr=value] - 匹配属性
|
|
||||||
*/
|
|
||||||
export function findTrackerIndex(emmet: string): number {
|
|
||||||
const {tag, id, classes, attributes} = parseEmmet(emmet);
|
|
||||||
|
|
||||||
return tracker.items.findIndex(item => {
|
|
||||||
// 匹配 tag
|
|
||||||
if (tag && item.tag !== tag) return false;
|
|
||||||
// 匹配 id
|
|
||||||
if (id && item.id !== id) return false;
|
|
||||||
// 匹配所有 classes
|
|
||||||
if (classes.length > 0) {
|
|
||||||
for (const cls of classes) {
|
|
||||||
if (!item.classes.includes(cls)) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 匹配属性
|
|
||||||
for(const attrName in attributes){
|
|
||||||
const attr = attributes[attrName];
|
|
||||||
if (item.attributes[attrName] !== attr) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findTrackerItem(emmet: string): TrackerItem | undefined {
|
|
||||||
const index = findTrackerIndex(emmet);
|
|
||||||
return index >= 0 ? tracker.items[index] : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addTrackerItem(item: Omit<TrackerItem, "uuid">): TrackerItem {
|
|
||||||
const newItem: TrackerItem = {
|
|
||||||
...item,
|
|
||||||
};
|
|
||||||
|
|
||||||
setTracker("items", (prev) => [...prev, newItem]);
|
|
||||||
setTracker("history", (prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
type: "add",
|
|
||||||
itemId: newItem.id,
|
|
||||||
data: newItem,
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return newItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeTrackerItem(emmet: string): boolean {
|
|
||||||
const index = findTrackerIndex(emmet);
|
|
||||||
if (index === -1) return false;
|
|
||||||
|
|
||||||
const item = tracker.items[index];
|
|
||||||
|
|
||||||
setTracker("items", (prev) => prev.filter((_, i) => i !== index));
|
|
||||||
setTracker("history", (prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
type: "remove",
|
|
||||||
itemId: `${item.tag}${item.id ? '#' + item.id : ''}`,
|
|
||||||
data: item,
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateTrackerAttribute(
|
|
||||||
emmet: string,
|
|
||||||
attrName: string,
|
|
||||||
attr: TrackerAttribute
|
|
||||||
): boolean {
|
|
||||||
const index = findTrackerIndex(emmet);
|
|
||||||
if (index === -1) return false;
|
|
||||||
|
|
||||||
const item = tracker.items[index];
|
|
||||||
setTracker("items", (prev) =>
|
|
||||||
prev.map((i, idx) =>
|
|
||||||
idx === index
|
|
||||||
? { ...i, attributes: { ...i.attributes, [attrName]: attr } }
|
|
||||||
: i
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setTracker("history", (prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
type: "update",
|
|
||||||
itemId: `${item.tag}${item.id ? '#' + item.id : ''}`,
|
|
||||||
attributeUpdates: { [attrName]: attr },
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveTrackerItem(emmet: string, direction: "up" | "down"): boolean {
|
|
||||||
const index = findTrackerIndex(emmet);
|
|
||||||
if (index === -1) return false;
|
|
||||||
|
|
||||||
const newIndex = direction === "up" ? index - 1 : index + 1;
|
|
||||||
if (newIndex < 0 || newIndex >= tracker.items.length) return false;
|
|
||||||
|
|
||||||
const newItems = [...tracker.items];
|
|
||||||
[newItems[index], newItems[newIndex]] = [newItems[newIndex], newItems[index]];
|
|
||||||
|
|
||||||
setTracker("items", newItems);
|
|
||||||
setTracker("history", (prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
type: "reorder",
|
|
||||||
data: newItems,
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeTrackerClass(emmet: string, className: string): boolean {
|
|
||||||
const index = findTrackerIndex(emmet);
|
|
||||||
if (index === -1) return false;
|
|
||||||
|
|
||||||
const item = tracker.items[index];
|
|
||||||
setTracker("items", (prev) =>
|
|
||||||
prev.map((i, idx) =>
|
|
||||||
idx === index
|
|
||||||
? { ...i, classes: i.classes.filter((c) => c !== className) }
|
|
||||||
: i
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setTracker("history", (prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
type: "update",
|
|
||||||
itemId: `${item.tag}${item.id ? '#' + item.id : ''}`,
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateTrackerClasses(emmet: string, classes: string[]): boolean {
|
|
||||||
const index = findTrackerIndex(emmet);
|
|
||||||
if (index === -1) return false;
|
|
||||||
|
|
||||||
const item = tracker.items[index];
|
|
||||||
setTracker("items", (prev) =>
|
|
||||||
prev.map((i, idx) =>
|
|
||||||
idx === index
|
|
||||||
? { ...i, classes: [...classes] }
|
|
||||||
: i
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setTracker("history", (prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
type: "update",
|
|
||||||
itemId: `${item.tag}${item.id ? '#' + item.id : ''}`,
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTrackerItems(): TrackerItem[] {
|
|
||||||
return tracker.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTrackerHistory(): TrackerCommand[] {
|
|
||||||
return tracker.history;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearTrackerHistory() {
|
|
||||||
setTracker("history", []);
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import type { Component } from "solid-js";
|
|
||||||
|
|
||||||
// ==================== 组件 Props ====================
|
|
||||||
|
|
||||||
export interface MdCommanderProps {
|
|
||||||
placeholder?: string;
|
|
||||||
class?: string;
|
|
||||||
height?: string;
|
|
||||||
commands?: Record<string, MdCommanderCommand>;
|
|
||||||
commandTemplates?: string | string[]; // CSV 文件路径或路径数组
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 命令定义 ====================
|
|
||||||
|
|
||||||
export interface MdCommanderCommandMap {
|
|
||||||
[key: string]: MdCommanderCommand;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MdCommanderCommand {
|
|
||||||
command: string;
|
|
||||||
description?: string;
|
|
||||||
parameters?: MdCommanderParameter[];
|
|
||||||
options?: Record<string, MdCommanderOption>;
|
|
||||||
handler?: (args: {
|
|
||||||
params: Record<string, string>;
|
|
||||||
options: Record<string, string>;
|
|
||||||
}, commands?: MdCommanderCommandMap) => {
|
|
||||||
message: string;
|
|
||||||
type?: "success" | "error" | "info" | "warning";
|
|
||||||
isHtml?: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MdCommanderParameter {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
type: MdCommanderOptionType;
|
|
||||||
required?: boolean;
|
|
||||||
default?: string;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
values?: string[];
|
|
||||||
templates?: MdCommanderTemplate[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MdCommanderTemplate {
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
insertText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MdCommanderOptionType =
|
|
||||||
| "string"
|
|
||||||
| "number"
|
|
||||||
| "enum"
|
|
||||||
| "tag"
|
|
||||||
| "boolean";
|
|
||||||
|
|
||||||
export interface MdCommanderOption {
|
|
||||||
option: string;
|
|
||||||
description?: string;
|
|
||||||
type: MdCommanderOptionType;
|
|
||||||
required?: boolean;
|
|
||||||
default?: any;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
values?: string[];
|
|
||||||
EntryComponent?: Component<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 命令执行记录 ====================
|
|
||||||
|
|
||||||
export interface CommanderEntry {
|
|
||||||
id: string;
|
|
||||||
command: string;
|
|
||||||
args: Record<string, any>;
|
|
||||||
result: {
|
|
||||||
message: string;
|
|
||||||
type?: "success" | "error" | "warning" | "info";
|
|
||||||
isHtml?: boolean;
|
|
||||||
};
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompletionItem {
|
|
||||||
label: string;
|
|
||||||
type: "command" | "option" | "value";
|
|
||||||
description?: string;
|
|
||||||
insertText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Tracker ====================
|
|
||||||
|
|
||||||
export type TrackerViewMode = "history" | "tracker";
|
|
||||||
|
|
||||||
export type TrackerAttributeType = "progress" | "count" | "string";
|
|
||||||
|
|
||||||
export interface TrackerAttribute {
|
|
||||||
name: string;
|
|
||||||
type: TrackerAttributeType;
|
|
||||||
value: string | number | { x: number; y: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrackerItem {
|
|
||||||
tag: string;
|
|
||||||
id?: string;
|
|
||||||
classes: string[];
|
|
||||||
attributes: Record<string, TrackerAttribute>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrackerCommand {
|
|
||||||
type: "add" | "remove" | "update" | "reorder";
|
|
||||||
itemId?: string;
|
|
||||||
data?: Partial<TrackerItem> | TrackerItem[];
|
|
||||||
attributeUpdates?: Record<string, TrackerAttribute>;
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import type { TrackerAttribute } from "../types";
|
|
||||||
|
|
||||||
export interface ParsedEmmet {
|
|
||||||
tag: string;
|
|
||||||
id?: string;
|
|
||||||
classes: string[];
|
|
||||||
attributes: Record<string, TrackerAttribute>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 Emmet 风格的 tracker 语法
|
|
||||||
* 格式:tag#id.class1.class2[attr1=value1 attr2=value2]
|
|
||||||
* 示例:npc#john.dwarf.warrior[hp=4/4 ac=15 name="John the Dwarf"]
|
|
||||||
*/
|
|
||||||
export function parseEmmet(input: string): ParsedEmmet {
|
|
||||||
const result: ParsedEmmet = {
|
|
||||||
tag: "",
|
|
||||||
classes: [],
|
|
||||||
attributes: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!input) return result;
|
|
||||||
|
|
||||||
// 匹配属性部分 [...]
|
|
||||||
const attrMatch = input.match(/\[(.+)\]$/);
|
|
||||||
let attrString: string | undefined;
|
|
||||||
let mainPart: string;
|
|
||||||
|
|
||||||
if (attrMatch) {
|
|
||||||
attrString = attrMatch[1];
|
|
||||||
mainPart = input.slice(0, attrMatch.index);
|
|
||||||
} else {
|
|
||||||
mainPart = input;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 tag、id 和 classes
|
|
||||||
// 格式:tag#id.class1.class2.class3
|
|
||||||
const tagClassMatch = mainPart.match(/^([^#.\s]+)?(?:#([^.\s]+))?(?:\.([^.]+(?:\.[^.]+)*))?/);
|
|
||||||
|
|
||||||
if (tagClassMatch) {
|
|
||||||
if (tagClassMatch[1]) {
|
|
||||||
result.tag = tagClassMatch[1];
|
|
||||||
}
|
|
||||||
if (tagClassMatch[2]) {
|
|
||||||
// # 后面的是 ID,不是 class
|
|
||||||
result.id = tagClassMatch[2];
|
|
||||||
}
|
|
||||||
if (tagClassMatch[3]) {
|
|
||||||
result.classes.push(...tagClassMatch[3].split("."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析属性
|
|
||||||
if (attrString) {
|
|
||||||
result.attributes = parseAttributes(attrString);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析属性字符串
|
|
||||||
* 支持格式:
|
|
||||||
* - hp=4/4 (progress 类型,自动检测)
|
|
||||||
* - count=5 (count 类型,纯数字)
|
|
||||||
* - name="John" (string 类型,带引号)
|
|
||||||
* - name=John (string 类型,不带引号)
|
|
||||||
*/
|
|
||||||
function parseAttributes(attrString: string): Record<string, TrackerAttribute> {
|
|
||||||
const attributes: Record<string, TrackerAttribute> = {};
|
|
||||||
|
|
||||||
// 匹配键值对:使用,分隔
|
|
||||||
const regex = /(\w+)=(?:"([^"]*)"|'([^']*)'|([^,]+))/g;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((match = regex.exec(attrString)) !== null) {
|
|
||||||
const key = match[1];
|
|
||||||
// 匹配的值可能是第 2、3 或 4 组(取决于引号类型)
|
|
||||||
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
|
||||||
|
|
||||||
// 自动检测类型
|
|
||||||
let attr: TrackerAttribute;
|
|
||||||
|
|
||||||
// 检查是否是 progress 格式 (x/y)
|
|
||||||
const progressMatch = value.match(/^(\d+)\/(\d+)$/);
|
|
||||||
if (progressMatch) {
|
|
||||||
attr = {
|
|
||||||
name: key,
|
|
||||||
type: "progress",
|
|
||||||
value: { x: parseInt(progressMatch[1]), y: parseInt(progressMatch[2]) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// 检查是否是纯数字(count 类型)
|
|
||||||
else if (/^\d+$/.test(value)) {
|
|
||||||
attr = {
|
|
||||||
name: key,
|
|
||||||
type: "count",
|
|
||||||
value: parseInt(value),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// 默认 string 类型
|
|
||||||
else {
|
|
||||||
attr = {
|
|
||||||
name: key,
|
|
||||||
type: "string",
|
|
||||||
value: value.replace(/^["']|["']$/g, ""), // 移除可能的引号
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
attributes[key] = attr;
|
|
||||||
}
|
|
||||||
|
|
||||||
return attributes;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { parseEmmet } from './emmetParser';
|
|
||||||
export type { ParsedEmmet } from './emmetParser';
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { For, Show, createEffect, createSignal, on } from "solid-js";
|
import { For, Show, createEffect, createSignal, on } from "solid-js";
|
||||||
|
import { TbOutlineChevronLeft, TbOutlineChevronRight } from "solid-icons/tb";
|
||||||
import type { DeckStore } from "./hooks/deckStore";
|
import type { DeckStore } from "./hooks/deckStore";
|
||||||
|
|
||||||
export interface CardListProps {
|
export interface CardListProps {
|
||||||
@@ -67,7 +68,7 @@ export function CardList(props: CardListProps) {
|
|||||||
disabled={page() === 0}
|
disabled={page() === 0}
|
||||||
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||||
>
|
>
|
||||||
‹
|
<TbOutlineChevronLeft />
|
||||||
</button>
|
</button>
|
||||||
<span class="text-xs text-gray-500 tabular-nums">
|
<span class="text-xs text-gray-500 tabular-nums">
|
||||||
{page() + 1} / {totalPages()}
|
{page() + 1} / {totalPages()}
|
||||||
@@ -77,7 +78,7 @@ export function CardList(props: CardListProps) {
|
|||||||
disabled={page() >= totalPages() - 1}
|
disabled={page() >= totalPages() - 1}
|
||||||
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||||
>
|
>
|
||||||
›
|
<TbOutlineChevronRight />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Show, createSignal, onMount } from "solid-js";
|
import { Show, createSignal, onMount } from "solid-js";
|
||||||
import { Portal } from "solid-js/web";
|
import { Portal } from "solid-js/web";
|
||||||
|
import {
|
||||||
|
TbOutlineCheck,
|
||||||
|
TbOutlineCopy,
|
||||||
|
TbOutlineDownload,
|
||||||
|
TbOutlinePencil,
|
||||||
|
} from "solid-icons/tb";
|
||||||
import type { DeckStore } from "./hooks/deckStore";
|
import type { DeckStore } from "./hooks/deckStore";
|
||||||
|
|
||||||
export interface DeckHeaderProps {
|
export interface DeckHeaderProps {
|
||||||
@@ -43,7 +49,15 @@ export function DeckHeader(props: DeckHeaderProps) {
|
|||||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{store.state.isEditing ? "✓ 编辑中" : "✏️ 编辑"}
|
{store.state.isEditing ? (
|
||||||
|
<>
|
||||||
|
<TbOutlineCheck class="inline-block align-[-0.125em]" /> 编辑中
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TbOutlinePencil class="inline-block align-[-0.125em]" /> 编辑
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 导出 PDF 按钮 */}
|
{/* 导出 PDF 按钮 */}
|
||||||
@@ -51,7 +65,7 @@ export function DeckHeader(props: DeckHeaderProps) {
|
|||||||
onClick={() => store.actions.exportDeck()}
|
onClick={() => store.actions.exportDeck()}
|
||||||
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-green-100 text-green-600 hover:bg-green-200"
|
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-green-100 text-green-600 hover:bg-green-200"
|
||||||
>
|
>
|
||||||
📥 导出 PDF
|
<TbOutlineDownload class="inline-block align-[-0.125em]" /> 导出 PDF
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 复制代码按钮 */}
|
{/* 复制代码按钮 */}
|
||||||
@@ -59,7 +73,7 @@ export function DeckHeader(props: DeckHeaderProps) {
|
|||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-purple-100 text-purple-600 hover:bg-purple-200"
|
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-purple-100 text-purple-600 hover:bg-purple-200"
|
||||||
>
|
>
|
||||||
📋 复制代码
|
<TbOutlineCopy class="inline-block align-[-0.125em]" /> 复制代码
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user