From fda3e42784964f767b8d2a68d0c4ec1850eaeb36 Mon Sep 17 00:00:00 2001 From: weekbin Date: Sat, 3 Oct 2026 16:52:21 +0800 Subject: [PATCH] Add chinese-divination: Plum Blossom divination Mini App MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 梅花易数起卦与解卦。时间、数字、铜钱摇卦、每日一卦四种起法,按体用生克、京房八宫、 纳支、旬空月破、爻之合刑等传统规则断吉凶,落到所问之事上给应期与宜忌;内置六十四卦 全文(卦辞、彖传、三百八十四条爻辞与小象传)与干支时辰历法。既能在页面起卦,也能由 Agent 经 MCP 端点在对话里主动起卦。 安全与边界: - 全部计算在本机完成:不联网、不调模型、不 spawn 进程,不读包目录以外的任何文件 - 卦历存在 Host 分配的 dataDir,原子写入,上限 500 条;坏盘另存而非删除 - 请求只认回环 Host/Origin,挡住 DNS rebinding 与跨源读取;403 不回显请求方 - 日志只记错误码,不记 error.message——fs 报错会把绝对路径与系统用户名带出去 平台与验证: - 三个平台(Windows / macOS / Linux)均已实跑:安装、经 Agent 打开、四个分区走通 - 路径审计 0 问题:可移植路径、Windows 保留名、UTF-8 无 BOM 无 CRLF、大小写、 import 解析、最坏 dataDir 路径 112 字符(MAX_PATH 260 内) - 228 项单元测试,在 UTC-8 到 UTC+14 四个时区与 C locale 下均为 228 通过 0 失败 - 冒烟脚本真起 start(context),83 项覆盖页面、四种起法、卦历增删查、同秒 id 撞车、 坏盘恢复、MCP 全路径与 dispose 幂等 - 尚未核对的一项:760px 断点以下的窄屏版式 --- README.md | 1 + README.zh-CN.md | 1 + .../.minimax-plugin/plugin.json | 14 + plugins/weekbin/chinese-divination/LICENSE | 21 + plugins/weekbin/chinese-divination/README.md | 1721 +++++ .../chinese-divination/README.zh-CN.md | 1298 ++++ plugins/weekbin/chinese-divination/icon.png | Bin 0 -> 143528 bytes .../miniapp/client/index.html | 3622 ++++++++++ .../chinese-divination/miniapp/miniapp.json | 21 + .../miniapp/node/almanac.mjs | 453 ++ .../miniapp/node/divination.mjs | 1862 ++++++ .../chinese-divination/miniapp/node/guaqi.mjs | 95 + .../miniapp/node/hexagrams.mjs | 259 + .../miniapp/node/jingfang.mjs | 1550 +++++ .../miniapp/node/mcp/divination-http.mjs | 562 ++ .../miniapp/node/miniapp-api.ts | 109 + .../miniapp/node/server.mjs | 377 ++ .../chinese-divination/miniapp/node/store.mjs | 181 + .../miniapp/node/topics.mjs | 179 + .../chinese-divination/miniapp/node/tuan.mjs | 86 + .../miniapp/node/xiang-chuan.mjs | 545 ++ .../chinese-divination/miniapp/node/xiang.mjs | 48 + .../chinese-divination/miniapp/node/yao.mjs | 546 ++ .../weekbin/chinese-divination/package.json | 6 + .../chinese-divination/servers.mcp.json | 12 + .../skills/divination/SKILL.md | 57 + .../tests/divination.test.mjs | 5856 +++++++++++++++++ 27 files changed, 19482 insertions(+) create mode 100644 plugins/weekbin/chinese-divination/.minimax-plugin/plugin.json create mode 100644 plugins/weekbin/chinese-divination/LICENSE create mode 100644 plugins/weekbin/chinese-divination/README.md create mode 100644 plugins/weekbin/chinese-divination/README.zh-CN.md create mode 100644 plugins/weekbin/chinese-divination/icon.png create mode 100644 plugins/weekbin/chinese-divination/miniapp/client/index.html create mode 100644 plugins/weekbin/chinese-divination/miniapp/miniapp.json create mode 100644 plugins/weekbin/chinese-divination/miniapp/node/almanac.mjs create mode 100644 plugins/weekbin/chinese-divination/miniapp/node/divination.mjs create mode 100644 plugins/weekbin/chinese-divination/miniapp/node/guaqi.mjs create mode 100644 plugins/weekbin/chinese-divination/miniapp/node/hexagrams.mjs create mode 100644 plugins/weekbin/chinese-divination/miniapp/node/jingfang.mjs create mode 100644 plugins/weekbin/chinese-divination/miniapp/node/mcp/divination-http.mjs create mode 100644 plugins/weekbin/chinese-divination/miniapp/node/miniapp-api.ts create mode 100644 plugins/weekbin/chinese-divination/miniapp/node/server.mjs create mode 100644 plugins/weekbin/chinese-divination/miniapp/node/store.mjs create mode 100644 plugins/weekbin/chinese-divination/miniapp/node/topics.mjs create mode 100644 plugins/weekbin/chinese-divination/miniapp/node/tuan.mjs create mode 100644 plugins/weekbin/chinese-divination/miniapp/node/xiang-chuan.mjs create mode 100644 plugins/weekbin/chinese-divination/miniapp/node/xiang.mjs create mode 100644 plugins/weekbin/chinese-divination/miniapp/node/yao.mjs create mode 100644 plugins/weekbin/chinese-divination/package.json create mode 100644 plugins/weekbin/chinese-divination/servers.mcp.json create mode 100644 plugins/weekbin/chinese-divination/skills/divination/SKILL.md create mode 100644 plugins/weekbin/chinese-divination/tests/divination.test.mjs diff --git a/README.md b/README.md index 20eaf54..a786dee 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Model Manager](plugins/ocoomber/openrouter-model-manager/) | Browse, search, and enable/disable models in your `~/.minimax/config.yaml` with instant save, bulk actions, one-click undo, and automatic backups | [ocoomber](https://github.com/ocoomber) | | [Self-drive Route Planner](plugins/hanzijie/self-drive-route-planner/) | **Official plugin** for planning driving routes with place search, route alternatives, demo mode, and Xiaohongshu 3:4 itinerary cards | [HanZijie](https://github.com/HanZijie) | | [Git Commit Tree](plugins/microbiosis/git-tree/) | Inspect a local Git repo's commit history with a swim-lane graph, branches/tags, commit detail, and per-file change stats; persisted filter preferences and optional auto-refresh | [Microbiosis](https://github.com/Microbiosis) | +| [灵签易占 / Chinese Divination](plugins/weekbin/chinese-divination/) | Cast hexagrams with the Plum Blossom method (time, number, three-coin, or daily), read the result through the classical body/use rules, and browse all sixty-four hexagrams with a ganzhi almanac | [weekbin](https://github.com/weekbin) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index e18ec61..bc725e1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -65,6 +65,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [模型管理器](plugins/ocoomber/openrouter-model-manager/README.zh-CN.md) | 浏览、搜索并启用/停用 `~/.minimax/config.yaml` 中的模型,支持即时保存、批量操作、一键撤销和自动备份 | [ocoomber](https://github.com/ocoomber) | | [自驾规划](plugins/hanzijie/self-drive-route-planner/README.zh-CN.md) | 【官方插件】规划自驾路线、地点搜索、候选算路与小红书 3:4 行程图;支持演示模式 | [HanZijie](https://github.com/HanZijie) | | [Git 提交树](plugins/microbiosis/git-tree/README.zh-CN.md) | 查看本机 Git 仓库的提交历史:泳道提交图、分支/标签、提交详情与文件改动统计,支持筛选偏好持久化与可选自动刷新 | [Microbiosis](https://github.com/Microbiosis) | +| [灵签易占](plugins/weekbin/chinese-divination/README.zh-CN.md) | 梅花易数起卦与解卦:时间、数字、铜钱摇卦、每日一卦四法起卦,按体用生克断吉凶,另备六十四卦全文与干支历法 | [weekbin](https://github.com/weekbin) |
预览:Token 用量看板 diff --git a/plugins/weekbin/chinese-divination/.minimax-plugin/plugin.json b/plugins/weekbin/chinese-divination/.minimax-plugin/plugin.json new file mode 100644 index 0000000..5a434a9 --- /dev/null +++ b/plugins/weekbin/chinese-divination/.minimax-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "name": "chinese-divination", + "displayName": "灵签易占", + "version": "1.1.0", + "description": "梅花易数起卦与解卦:时间、数字、铜钱摇卦、每日一卦四种起法,问事定事类与应期,含体用生克断卦、六十四卦全文与干支时辰历法。既能打开页面起卦,也能在对话里由 Agent 主动调用。", + "author": "weekbin", + "icon": "icon.png", + "category": "Other", + "exampleQueries": ["打开灵签易占", "帮我起一卦", "帮我看看这份offer该不该接", "掷铜钱起一卦", "查一下今天黄历"], + "apps": [], + "mcpServers": ["servers.mcp.json"], + "skills": ["skills/divination/SKILL.md"] +} diff --git a/plugins/weekbin/chinese-divination/LICENSE b/plugins/weekbin/chinese-divination/LICENSE new file mode 100644 index 0000000..211e5d5 --- /dev/null +++ b/plugins/weekbin/chinese-divination/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 weekbin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/weekbin/chinese-divination/README.md b/plugins/weekbin/chinese-divination/README.md new file mode 100644 index 0000000..d2ec1a8 --- /dev/null +++ b/plugins/weekbin/chinese-divination/README.md @@ -0,0 +1,1721 @@ +# 灵签易占 (`chinese-divination`) + +English | [简体中文](README.zh-CN.md) + +A Chinese classical divination Mini App. It casts hexagrams with the Plum Blossom method +(梅花易数), reads the result through the classical 体用生克 rules, and ships a searchable +library of all sixty-four hexagrams plus a ganzhi almanac. Everything runs locally; the app +makes no network requests and reads nothing outside its own package. + +The interface is in Chinese. To get going, read the **Usage guide** below; the derivation rules +and capability disclosures follow it. + +## Usage guide + +### Thirty seconds + +After installing, say "打开灵签易占" to MiniMax Code. The page opens on four tabs: +**起卦 / 卦库 / 历法 / 卦历** (cast / library / almanac / log). + +To cast inside the conversation instead, just say "帮我起一卦" — see +[casting in the conversation](#casting-in-the-conversation). + +### Asking properly + +**The first field is the one that matters.** What you type into 所问何事 decides which kind of +matter you are asking about, and that changes what the reading emphasises: + +| You write | Topic recognised | 应期 points to | +| --- | --- | --- | +| 下个月这份 offer 该不该接 | 事业功名 (career) | 巳午, then 辰戌丑未 | +| 这笔投资还能不能赚 | 财运 (wealth) | 申酉, then 亥子丑 | +| 他会不会主动来找我 | 感情 (love) | 亥子, then 寅卯 | +| 明年春天结婚日子好不好 | 婚恋 (marriage) | 寅卯, then 巳午 | +| 父亲的手术要不要等 | 疾病 (health) | 辰戌丑未, then 申酉 | +| 这套房该不该买 | 房产车契 (property) | 辰戌丑未, then 申酉 | +| 下周的考试能过吗 | 学业文书 (study) | 寅卯, then 巳午 | + +Be specific. "Should I take this offer" gets a topic, a 类神, and a real 应期; "how's my luck +lately" gets nothing and falls back to the 用卦. If what you wrote genuinely does not match a +topic, the app does not force one — it says so in the reading and falls back to the 用卦. +**"You wrote nothing" and "you wrote something this table does not cover" are different, and the +reading says so differently.** The second case lists the nine topic classes it does cover, naming +the layer that is missing, instead of telling you that you failed to ask. + +**Your question does not change the verdict.** The same hexagram asked about money and asked +about marriage cannot flip from 凶 to 吉: 吉凶 comes from the hexagram's own 体用生克 and the +month's vitality. What your question moves is the 应期, which 取象 is emphasised, and the one +sentence at the top of the result that names the thing you actually asked about — 「问的是事业功名, +卦里说的就是这份前程」. Rewording swaps that sentence, never the 吉凶. This is deliberate — +divination should help you think, not let you buy a good answer by phrasing. + +That sentence needs the question to name a recognisable matter. The nine classes it covers are +listed under 「所问之事」; write about something outside them and the line is simply left off +rather than filled with a phrase that happens to fit. + +### Picking a method + +| Your situation | Use | Changes every | +| --- | --- | --- | +| You have a "what is happening right now" question | **时间起卦** | 时辰 (two hours) | +| You just want today's picture | **每日一卦** | once a day | +| You already know what to ask and want to pick your own numbers | **数字起卦** | whenever you change the numbers | +| You want something genuinely random | **铜钱摇卦** | every single toss | + +The first two derive their numbers from the clock, so casting twice inside the same 时辰 or on +the same day gives the same hexagram. That is the method working, not a stuck program — the +reading states its own cadence, including the hexagram the next change will produce. Use coins or +numbers when you want variety. + +### Using 数字起卦 + +1. Click 数字起卦; the number fields appear. +2. Quietly hold the question in mind and **think of two numbers** (any two will do). +3. Fill them in and click 起卦. First gives the upper trigram, second the lower, their sum the + moving line. +4. The fields clear themselves afterwards, so clicking twice cannot look like a frozen result. + +「随手取数」 picks two random 1–99 numbers for you. + +### Using 铜钱摇卦 + +Click 铜钱摇卦, then 掷钱 six times from the bottom line up. A progress bar and a 「已摇 n / 6 爻」 +counter sit above the buttons and move with every toss, so you can see how many lines are still +missing without counting them. Each toss is shown as it lands; when six lines are in, click 成卦解卦. +「重来」 starts over. This is the only method that differs every single time. + +### Reading the result + +| Where | What | +| --- | --- | +| Title | your question if you wrote one, otherwise the method's name | +| Verdict block | full width, directly under the title and **above** both columns, so the plain-language answer is the first thing on the page rather than something buried mid-scroll. In order: the verdict in large type with its one-word action (大吉 可进), the sentence saying why in 体用生克 terms, the sentence saying what the hexagram is talking about *in your case*, a grey line showing how the score was reached, 宜 / 忌, and any caution. The derivation below it is the 「凭什么」 — read it or don't, it stays where it is | +| Top right | the same verdict and the 体用 relation, as a badge. It is the same number the block leads with; nothing on the page states two different 吉凶 | +| Left | 本卦 and 变卦 as six-line diagrams with 卦辞 and 象辞; each line carries its 六神 to the left of the bars, and the 干支 and 六亲 to the right, with 世 and 应 boxed in red, a 伏神 under its line in dashed small type, and 空 / 破 / 墓 / 暗 / 日破 / 冲散 as small marks, with the 用神's 元神 / 忌神 / 仇神 tagged 元 / 忌 / 仇 on their own lines; moving lines marked in red, the 动爻's 爻辞 with its 象传 quoted under the 本卦; and, on the eighteen 六冲/六合 hexagrams, three thin arcs in the margin joining 初四、二五、三六 | +| Right | 体卦/用卦 elements and directions, 主客, 六亲世应, 卦体冲合 (whether this hexagram is 六冲 or 六合, and what the changed one is), 用神 (saying where the line sits, or which line the 伏神 hides under), 暗动 · 日破 · 冲散 when the day's branch clashes a line, 爻之合 (合起, 合绊, 合好, 化扶 on each line that meets a combination), 爻之刑 (who punishes whom), the month's vitality, the 旬空 and the month's 月破, the 八宫名单 (one palace's eight hexagrams in generation order, each marking the lines it flips), the four-derivation diagram, and the 消长 ring | +| 断语 | seventeen to twenty-two sections, with 【动爻爻辞】 as the second; 【暗动 · 日破 · 冲散】 is inserted after 【用神】 only when the day's branch actually clashes a line (about 46% of readings); 【犯刑】 follows 【逢合】 and appears only when a line really is punished by another line or by the day's or the month's branch (about 90% of readings — 37 of the sixty-four have 纳甲 that collides on its own, and the rest are covered by the day/month path), and 【六冲】 only when the hexagram itself is 六冲 or 六合, or the changed hexagram is, or a moving line clashes its own transformed line (about 48%); 【反伏与卦变】 follows 【犯刑】 and appears only when there really is a 反伏 or a 卦变 — about 6.4% of hand-tossed readings, and never under 时间, 每日 or 数字 casting, all three of which move exactly one line | +| 起卦依据 | every number that went into the cast, shown rather than hidden | +| 存入卦历 | add a one-line note and keep it on this machine | + +The **应期** section is the most practically useful: it names the months and days when the +matter is likely to show itself. + +### 卦历 — your own log + +Saved castings appear newest first, with the question, hexagram, verdict, time and note, and can +be deleted one at a time. Up to 500 entries. They live only on this machine. + +### Casting in the conversation (MCP) + +Besides opening the page, the Agent can cast for you **without the page ever opening** — it calls +this package's registered MCP endpoint, which runs the same derivation on this machine. Both +routes use identical logic and give identical results. + +#### Making sure it works + +1. The package must be **installed** into MiniMax Code (copied to + `~/.minimax/plugins/chinese-divination/`; see Install below). +2. **Restart MiniMax Code** so the Host scans the plugin directory and registers the MCP server. +3. Then just talk to it. No page needed. + +#### What to say, and what the Agent does + +| You say | Agent calls | Key arguments | +| --- | --- | --- | +| 帮我起一卦 / what's happening right now | `divination_cast` | `method: time` (the default) | +| 今天什么日子 / today's almanac | `divination_almanac` | no arguments | +| 谦卦什么意思 / look up 水雷屯 | `divination_hexagram_lookup` | `query: 谦` | +| 掷铜钱 / something random | `divination_cast` | `method: coins`; the tool tosses six times for you | +| 今天这一卦 | `divination_cast` | `method: daily` | +| I'm thinking of 3 and 8, cast with those | `divination_cast` | `method: numbers`, `upper: 3` `lower: 8` | + +You do **not** have to name a method — the Agent picks from your wording. To force one, say +「用时间起卦」「掷铜钱」「按今天的日子起」. + +**The one thing that matters: say what you are asking about.** The Agent puts your words into +`question` and works out the topic itself, then passes it as `topic`. "Cast me a hexagram" with +no subject still works, but no topic is recognised and the timing falls back to the 用卦. + +That division is deliberate. On the page there is nobody to read your question, so a keyword +table has to guess, and a phrasing like 「他对我还有没有真心」 matches nothing in it. In a +conversation the Agent has just read the question in full — it can file that under 感情 without +any keyword hitting. The keyword path stays as the fallback for when `topic` is omitted. + +#### The three tools, in detail + +**`divination_cast` — cast and interpret** + +| Argument | Type | Meaning | +| --- | --- | --- | +| `question` | string, ≤120 chars | What you are asking, in your own words | +| `topic` | `wealth` / `career` / `love` / `marriage` / `health` / `study` / `property` / `dispute` / `journey` | Which of the nine classes it is. **The Agent's call**, not a keyword lookup — omit it when unsure and the keyword table takes over. Sets the 类神五行, the 用神 and the 应期; never the 吉凶. An unrecognised value is rejected rather than ignored, so the Agent finds out instead of assuming it landed | +| `method` | `time` / `daily` / `numbers` / `coins` | Defaults to `time` | +| `upper` / `lower` | integer 1–1e9 | Only for `numbers`: the upper and lower trigram numbers | + +Two blocks come back: `content[0].text` is prose written for the model, and `structuredContent` is +for programs (`method` / `question` / `topic` / `hexagram` / `changed` / `verdict` / `useGod` / +`transforms` / `dayClash` / `clash` / `fanfu` / `timing` / `disclaimer`). `fanfu` names the branch this reading falls in (`kind` is 「内卦」, 「外卦」, 「内外」 or 「卦变」), says whether the inner and outer trigrams are 反伏, and on the 反伏 side gives the 纳支 set it came from and the one it changed to (`innerSwap` / `outerSwap`); `dayClash` gives the 爻 positions of any 暗动, +日破 and 冲散, and the header gains a 【日冲】 line only when there is something to say. `clash` gives +whether the hexagram is a 六冲卦 or a 六合卦, whether the changed one is, whether the pair is +六合变六冲 or 六冲变六冲, which moving lines clash their own transformed line, the three 爻 pairs with +each one's verdict, and any incidental 爻与爻冲 as 爻-position pairs; the header gains a 【卦体】 line +when there is something to say — which is most readings, so the line is kept to a few words. The header also gains a 【反伏与卦变】 line, on about 6.4% of readings, reporting the two branches separately because they cannot be merged. Both lines stay short on purpose. +`useGod` names the 六亲 taken and, when the 用神 is not on the hexagram, +carries the 伏神 as `hidden` — `position`, `hushen`, `feishen`, `flying` and an `emerges` verdict — +and, when a single 用神 is settled, carries its circle as `circle` — the 爻 positions of the 元神, 忌神 +and 仇神 with their elements under `circle.elements` — +so a model never has to dig the answer back out of the prose. A real response: + +```text +【起法】数字起卦 +【所问】下个月要不要接这个offer +所问事类:事业功名(由 Agent 指定),类神五行 火。 +【卦名】火地晋(第 35 卦,⚊⚋⚊⚋⚋⚋),上卦 离火、下卦 坤土 +【变卦】天地否(上卦 乾、下卦 坤);动爻去向 五爻化泄 +【爻象】初爻 静爻、二爻 静爻、三爻 静爻、四爻 静爻、五爻 老阴、上爻 静爻 +【体用】体卦 离火,用卦 坤土 +【京房】乾宫游魂卦,属金;世爻四爻持兄弟,应爻初爻为父母 +【卦体】本卦非六冲非六合 +【犯刑】月建与4爻酉自刑 +【月令旺衰】当令 金,体 囚、用 休 +【吉凶】大凶 —— 大凶:宜止 +【断语】 +【卦象总断】本卦火地晋,晋,康侯用锡马蕃庶,昼日三接。… +【所问之事】所问归「事业功名」,类神取火。…类神火生体卦离火… +(sixteen sections) +【宜】守成,不宜扩张、先处理内务再对外、避开正面对抗 +【忌】正面强争、额外投入与加码、在对方主场行事 +【起卦依据】第一数 3 除 8 余 3 → 离卦;第二数 8 除 8 余 8 → 坤卦;动爻 11 除 6 余 5 → 五爻 +【大白话】 +你问的是「下个月要不要接这个offer」。 +我把它归到「事业功名」这一类——这一类以火为事。 +这一卦给的是「大凶」,宜止。 +体卦离火是你,用卦坤土是那件事——这件事要你往外掏;你被局面困住,处境受制。照两人之间的关系本该是凶,你这个月的状态把它拉到了「大凶」。 +问的是事业功名,卦里说的就是这份前程——这件事要你往外掏,这一卦落在它上面不顺。 +时间上:巳午月或巳午日见端倪,到辰戌丑未前后渐明。 +该做的是:守成,不宜扩张、先处理内务再对外、避开正面对抗。别做的是:正面强争、额外投入与加码、在对方主场行事。 +体用相制:局面不在你手上,宜守宜退,不宜正面强求。 +【提示】本结果由传统占卜法按规则推演……(免责声明全文) +``` + +Read the top half and you have the apparatus; read the bottom half and you have the answer. + +**The last block is the one you are meant to read.** Everything above it is for the Agent to +work from — 卦名, 纳支, 六亲, 用神, the whole apparatus. 【大白话】 at the very end, just before +the disclaimer, is the same reading with the terminology taken out: + +- **体卦 is you, 用卦 is the matter at hand.** That is the one translation the page can never + make for you and the Agent never had to learn: a trigram called 体 is not your body, it is + whichever side holds the moving line, and that side is the querent. +- **旺相休囚死 is how much force you have right now**, not a ranking of five elements. 旺 is + 「你此刻最有力气」; 死 is 「你气力最弱,此时强推反而吃亏」. +- **The 生克 relation becomes a sentence about who is doing what to whom** — 体生用 reads as + 「这件事要你往外掏」, 用克体 as 「外头的力压着这件事」. +- **Where the two layers disagree, it says so.** 体克用 is 小吉 on 生克 alone, but a 体卦 on + death ground drags the total down to 平; the plain block spells that difference out instead of + leaving the reader to think the arithmetic went wrong. +- The topic sentence, the timing, the 宜/忌 and the caution ride along in the same register. + +Nothing in it is new judgement — every clause comes from a field the block above already +settled, and it stays silent about anything the reading never decided. The Agent is told in +`initialize` to relay that block rather than re-narrate the hexagram in technical language. + +**`divination_hexagram_lookup` — search the sixty-four** + +| Argument | Type | Meaning | +| --- | --- | --- | +| `query` | string, ≤40 chars | hexagram name, trigram name, or keyword. Omit for the full table | +| `limit` | integer 1–64, default 8 | how many to return | +| `detail` | `brief` / `full`, default `brief` | `brief` omits the 彖传 text (≈45% shorter), `full` includes it | + +**This tool never casts a hexagram for you.** Ask "what does 谦 mean" and you get 谦's texts — not +an unrelated new reading. + +The default is 卦辞 and 象辞 only. A lookup is usually a "what does this mean" question, and the +彖传 (about 61 characters per hexagram) is the layer of principle the Agent rarely needs, so it is +left out by default with a note at the end. Pass `detail: full` when you want it. + +```text +匹配「谦」的卦共 1 个,如下: + +【地山谦】第 15 卦,⚋⚋⚋⚊⚋⚋,上坤下艮 +卦辞:亨,君子有终。 +象辞:地中有山,谦;君子以裒多益寡,称物平施。 +京房:兑宫五世卦(属金),世五爻持子孙,应二爻为官鬼 +互卦 雷水解,错卦 天泽履,综卦 雷地豫 + +(以上省去了彖传原文;需要时传 detail="full" 补上。) +``` + +**`divination_almanac` — today's almanac** + +No arguments. Returns the four ganzhi pillars, the current solar term, the month's element, the +current 时辰 with its pillar and auspiciousness, the lucky hours, the 建除 day, and 数九. + +```text +【日期】2026-09-29 +【干支】丙午年 丁酉月 丙午日 戊子时 +【节气】白露,月建 丁酉(金) +【当前时辰】子时(23:00 - 01:00,司命·黄道吉时) +【黄黑道吉时】子时、寅时、卯时、午时、未时、酉时 +【建除十二神】收 +【数九】未入数九(数九只在三九、九九两段) +``` + +#### A full round trip + +> **You**: Should I take this offer next month? I'm torn. + +The Agent calls `divination_cast` with your words as `question` and `method: time`. Your wording +is recognised as 事业功名 with a 火 类神, so the 应期 lands on 巳午. It explains the hexagram in +its own words — **the interpretation is written live by the model; the hexagram is computed by this +package**. + +> **You**: And if I don't take it? + +It can cast again (same 时辰, same hexagram — say 「掷铜钱」 if you want a different one), look up +related hexagrams with `divination_hexagram_lookup`, or check dates with `divination_almanac`. + +The division of labour: **this package computes accurately; the Agent explains it in terms of +your situation.** The package never calls a model, makes no outbound request, and holds no +credentials. + +#### Troubleshooting + +**The Agent seems not to know this exists.** +Almost always a missing restart. The Host scans the plugin directory — and registers the MCP +server — only at startup. Restart MiniMax Code and ask again. + +**I said "cast me a hexagram" and it didn't.** +Be explicit: 「用梅花易数起一卦」or name the plugin. The bundled +`skills/divination/SKILL.md` already tells it when to cast versus merely look something up; when +it cannot tell, name the method yourself. + +**It left out the disclaimer.** +That should not happen — the skill requires it on every reading. Ask it to add it. + +**Can I use it in chat without installing?** +No. Both the page and the MCP endpoint need this package loaded from `~/.minimax/plugins/`. + +### FAQ + +**Same 时辰, same result twice?** +Yes. The clock is a fixed input, so the same 时辰 gives the same hexagram. Use coins or numbers +when you want variety. + +**Does my question actually do anything, or is it just reframing?** +It sets the topic and the 应期, not the verdict. In the same 时辰, "should I switch jobs" and +"should we wait for my father's surgery" produce the same hexagram and the same 吉凶, but the 应期 +lands on 巳午 versus 辰戌丑未 and the 取象 emphasises different things. That is how Plum Blossom +works; it is not something an AI made up. + +**Is it accurate?** +That depends on you. A hexagram does not predict the future; it turns an existing question around +so you can see your own situation and options more clearly. For medical, legal or financial +decisions, get a professional. + +**Who wrote all this text?** +The hexagrams are computed from the traditional rules by code. Organising the findings into fluent +Chinese is the AI's job. See "A note on use" below. + +**Where do my questions go?** +Nowhere. Saved castings only touch `context.dataDir` on your own machine. The app does not go +online and reads nothing outside its package. + +## What it does + +**起卦 — four ways to cast** + +| Method | How it works | +| --- | --- | +| 每日一卦 | Deterministic from today's date, so the same day always yields the same hexagram. | +| 时间起卦 | 年支序 + 公历月 + 日 gives the upper trigram, adding 时支序 gives the lower trigram and the moving line. Best for "right now" questions. | +| 数字起卦 | Two numbers thought of quietly: the first gives the upper trigram, the second the lower, their sum the moving line. | +| 铜钱摇卦 | Three coins, six tosses, from the bottom line up. 6 is 老阴, 7 少阳, 8 少阴, 9 老阳; 6 and 9 mark a moving line. | + +**解卦 — reading the result** + +The verdict is decided in exactly one place, from a single score. 体用生克 sets the weight — +the trigram holding the moving line is the 体卦 (you), the other is the 用卦 (the matter at +hand): 用生体 is +2, 体克用 and 比和 are +1, 体生用 is −1, 用克体 is −2. The body's 五行 +vitality in the current month — 旺相休囚死, measured against the month branch set by the nearest +节 — then adds or takes off a point. A score of +2 or more is 大吉, +1 吉, 0 平, −1 凶, −2 or +worse 大凶. The textbook verdict for the 生克 layer on its own (体克用 reads 小吉, 比和 reads 吉) +is kept and shown as the *reason* next to the total, never as a second answer: the page leads with +one number and explains the two layers that produced it. The 歌诀 reads 「当令者旺,令生者相, +生令者休,克令者囚,令克者死」, where +令 is the month: 旺 same element, 相 fed by the month, 休 feeding the month, 囚 overcoming the month, +死 overcome by it. The 囚 and 死 positions are the pair most easily transposed, so all eight +non-seasonal months are pinned at load time against 《增删卜易·四时旺相章》 — read it as 令 in 寅月 +and 寅月 gives 木旺, 火相, 水休, **金囚, 土死**. The four 四季土 months are deliberately left out +of that check: the same chapter adds a refinement there (a branch clashing the month counts 休囚, +the other keeps 余气), and it does not say which of the five states the 余气 side falls into, so +following it would mean guessing. 旺相 in 辰戌丑未 months is computed from the plain table. + +**How often each method changes** + +This is worth stating plainly, because the four methods do not refresh at the same rate: + +| Method | Refreshes | Same result again if you… | +| --- | --- | --- | +| 铜钱摇卦 | every toss | never — coins are random | +| 数字起卦 | whenever you change the two numbers | reuse the same numbers | +| 时间起卦 | every 时辰, i.e. every two hours | cast again inside the same 时辰 | +| 每日一卦 | once a day, at midnight | cast again on the same date | + +That is the method working, not the app repeating itself. Every reading therefore states its own +cadence on the card: the current basis, the next change, and — for the two time-based methods — the +hexagram the next change will produce, so you can watch the cycle rather than guess at it. + +**所问何事 — the question picks the topic and the timing** + +The question you type above the methods is matched to a **topic**, and the topic carries a **类神 +element**. This is what lets the same hexagram read differently from one hour to the next: + +| Topic | 类神 element | +| --- | --- | +| 财运 | 金 | +| 事业功名 | 火 | +| 感情 | 水 | +| 婚恋 | 木 | +| 疾病 | 土 | +| 学业文书 | 木 | +| 房产车契 | 土 | +| 官讼是非 | 金 | +| 出行寻物 | 水 | + +**The 类神 changes the timing and the imagery, never the verdict.** 吉凶 stays exactly where +体用生克 and the month's vitality put it, so the same hexagram asked about money and asked about +marriage cannot flip from 凶 to 吉 — only the 应期 months and the 取象 emphasis move. When no topic +is recognised, nothing is forced: the timing falls back to the 用卦 and says so. + +> This element-to-topic table is **this package's own convention**, not a transmitted one. Plum +> Blossom has no 六亲 用神 the way 六爻 divination does, so 京房's 六亲 and 纳甲 are given as a +> separate layer (see 「京房一层」 below) and never rewrite the 体用 verdict. + +**Fifteen sections, every time** + +卦象总断, 动爻爻辞, 体用关系, 旺衰应期, 卦气 for the month's governing hexagram, 互卦 for the +middle course, 变卦 for the outcome, 错卦 for the other side, 综卦 seen from the other position, +六亲世应, 用神, 主客, 取象 of both trigrams, 爻位 for the moving line's position, and 方所 for the +后天八卦 directions. A recognised topic adds one more, 所问之事, naming the topic, its 类神, and how that +element stands to the 体卦. + +Three more come and go with the reading. 暗动 · 日破 · 冲散 appears only when the day's branch really +does clash a line. 化爻 · 变出之爻 appears whenever anything moves. 六冲 appears when the hexagram +itself is 六冲 or 六合, or the changed one is, or a moving line clashes its own transformed line. So a +reading runs seventeen to twenty-two sections. + +**动爻爻辞 — the line that actually moved** + +卦辞 states the whole hexagram's tendency; the 爻辞 states the situation on the line that moved, +which is the one your question lands on. All 384 are stored, and the reading quotes the moving +one's under the 本卦 — one line, not six, because six would bury the hexagram. 爻题 follows the +traditional form (初九、六二、上六) and the 九 / 六 always agrees with whether that line is 阳 or 阴; +the tests check all 384 against the hexagram diagrams, so a line that drifts out of place fails +the suite. + +**动爻象传 — why that line reads that way** + +The 爻辞 is the judgement; the 小象传 is the ground for it. All 384 are stored too, and the +reading prints the moving line's underneath its 爻辞 in smaller type and appends it to the same +断语 section — one place, not two. Cross-checking two editions turned up places where they +disagree with each other and, in two spots, with the received text: 大有九四 needs 尫 (both +editions print 彭, which the 爻辞 does not), and 困六三 splits across them — 蒺藜 from one, +不祥 from the other. Those are pinned by tests, along with 需九五's 「酒食贞吉」, which the +second edition expands to 「需于酒食」 against every other source. + +Two spellings that are easy to "simplify" away are pinned by tests: 损's 已事遄往 keeps 已 +(already) while 革's 巳日乃革之 keeps 巳 (the sixth earthly branch), and 噬嗑's 噬乾胏 borrows 乾 +for 干 while 乾卦's 终日乾乾 means something else entirely. + +**彖传 — why this hexagram is shaped this way** + +The 卦辞 says what the hexagram is; the 彖传 says why. There is one per hexagram, all 64 stored, +printed between the 卦辞 and the 象辞 and set in a lighter tone behind a left rule to mark that it +is commentary rather than the hexagram's own voice. The long ones run to a hundred characters or +more: 乾's walks from 「大哉乾元」 to 「万国咸宁」, unpacking 元亨利贞 one step at a time. + +Cross-checking two editions turned up ten substantive differences; nine take the base text. 蒙's +「初筮告」 (the other edition prints 「初噬告」), 小畜's 「健而巽」 (missing from one edition), and +革's 「革而信之」 with 「巳日乃孚」 all agree with the 爻辞. The tenth is 乾 itself: the two +editions give 「保和大和」 and 「保合太和」, and the received text reads 太和. Each ruling is pinned +by a test, as is the traditional-to-simplified mapping, which is derived rather than recalled: a +simplified source is aligned position by position against the traditional base, only agreeing pairs +are kept, and three false pairs produced by source typos are then removed by hand. + +**The three seconds of casting — how the hexagram forms** + +Press a method and the trigram ring turns while the derivation types out line by line. The six +lines used to appear only in the result, unrelated to what the log was saying; now the hexagram +body grows with the derivation, one line at a time from the bottom. **The moving line is marked +only after all six are in** — marking it early gives the answer away, and the whole point of +casting is that the hexagram forms first and only then the moving line is settled. Timing is +covered statically: the wait must not be shorter than the typing budget, and six lines at the +minimum interval must still fit inside it, so 「the typing finished but the diagram did not」 cannot +slip through. With reduced motion on, all six appear at once. + +**The four derivations — where 互, 变, 错, 综 come from** + +The reading says 「互卦为XX」 and leaves the derivation invisible. This puts the cast hexagram on +one row and the four derived ones below, each arrow labelled with how it is taken: 互 takes lines +2-3-4 as the lower trigram and 3-4-5 as the upper; 变 flips the moving line; 错 inverts all six; +综 reverses their order. In 变, the line that actually moved is vermilion, so 「that one changed」 is +visible rather than stated. The four are not four parallel conclusions but four directions onto one +question. The derivations are themselves under test — 互 really is 2-3-4 and 3-4-5, 错 really +inverts, 综 really reverses, 变 moves only what moved — so a wrong line in the diagram fails the +suite. + +**京房一层 — 六亲, 世应, 纳甲** + +A layer that runs beside Plum Blossom rather than inside it. Plum Blossom takes the trigram holding +the moving line as the 体 and reads **me against this matter**; 京房 takes the palace and the +generation to read what each of the six lines *is* — which line is me (世), which is the other party +(应), and which person's what each line is under the five phases. The two answer different questions, +so 世应 belongs to 京房 and the Plum Blossom section is called 主客: two different 世爻 positions in +one reading is simply confusing. + +The eight palaces and their generations come from the 《京氏易传》, and this package **derives them +rather than transcribing a table**: change the first line for 一世, the first two for 二世, the first +three for 三世, the first four for 四世, the first five for 五世; flip the fifth-generation hexagram's +fourth line back for 游魂; then draw 游魂's lower three lines back for 归魂. The derived result is +checked against the transmitted palace order hexagram by hexagram, and all sixty-four match. 归魂 is +the clause people get wrong — it returns 游魂's *lower* three lines, so relative to the palace hexagram +only the fifth line differs. Written as "flip the fourth and fifth", the 归魂 column of all eight +palaces silently becomes another palace's 二世 hexagram, and a test pins that on its own. + +纳支 follows the 纳支歌诀 that has been in use for two thousand years (乾金甲子外壬午、坎水戊寅外戊申、 +艮土丙辰外丙戌、震木庚子外庚午、巽木辛丑外辛未、离火己卯外己酉、坤土乙未外癸丑、兑金丁巳外丁亥), +checked line by line. **The branches follow the trigram, not the palace**: 山水蒙 belongs to 离宫 +(a 阴 palace), but its lower 艮 and upper 坎 are both 阳, so all six of its lines take 阳 branches. The +六亲 take the palace's own element as "me" — what generates me is 父母, what I generate is 子孙, what +overcomes me is 官鬼, what I overcome is 妻财, and my own element is 兄弟; the element comes from the +branch, not from the 纳音. + +世爻 sits on the first line for 一世, the second for 二世, and so on, on the top line for a pure +palace hexagram, the fourth for 游魂 and the third for 归魂. 应爻 **pairs** with it three positions +away: 1 with 4, 2 with 5, 3 with 6, and back round. Taking that as a plain "世 + 3" sends a pure +palace hexagram or a 五世 hexagram to the eighth or ninth line, which do not exist — a test pins +exactly that. + +On the hexagram itself, the 纳甲 column sits to the right of the bars: 干支 fixes the element, 六亲 +fixes the identity, and 世 and 应 each get a red box. The reading gains a 六亲世应 section naming the +palace and generation, who the 世 and 应 lines are, how they stand to each other, and which 亲 the +moving line falls on — with what each 亲 covers. The 变卦 gets its own palace and generation; it never +inherits the 本卦's. + +**八宫名单 — the eight slots of one palace** + +The hard part of the 京房 layer is not which palace you are in; it is the **order** of the eight +hexagrams inside that palace. 一世 through 五世 walk the 世 line up one line at a time, which reads +like a ladder — and then 游魂 and 归魂 walk it back down (世 on the fourth and third line) while still +being called 世. Written out as "离宫一世卦", nobody would guess the ladder has two rungs that run +backwards. + +So all eight are laid out in one row, ahead of the four derivations — settle which palace and which +rung you are on before looking at how 梅花 derives anything. Each slot is a small hexagram marking the +lines it flips relative to the palace's pure hexagram, in 朱砂. The 朱砂 box is your hexagram, and the +generation is written under it. 游魂 and 归魂 have their generation in bold: they are the two +exceptions on that ladder, and without it the row reads as six of a kind. + +Your own hexagram can itself be the 游魂 or the 归魂 — sixteen of the sixty-four, a quarter of all +casts. That slot carries both marks at once, and 朱砂 wins over the bold: box and text both 朱砂, so +you can tell at a glance that it is yours *and* that yours is one of the exceptions. + +No 世 line is marked on the small hexagrams. The 世 is already boxed in 朱砂 on the hexagram to the +left, and marking it again here would be a second account for the reader to reconcile. This row is +about order only. + +The row is not a copied table. It is derived from the palace's pure hexagram by each generation's +flips, so it comes out of the same derivation as the palace and generation themselves — which is why +every slot's name and position matches the received ordering, all sixty-four of them. The whole roster, +including each slot's flipped lines, is frozen: the eight hexagrams of one palace read the same array, +so editing it after looking at one palace's row would silently feed the edit to every other hexagram +in that palace, with nothing on screen to show where it came from. + +**用神 — which line your question falls on** + +The 六亲 sit there until a question points at one of them. The first step of 六爻 reading is 取用神: +ask what this is about, take that 亲 as the 用神. This one **has transmitted rules behind it**, unlike +the element-to-topic table above, which is this package's own convention. + +| The question | 用神 | Source | +| --- | --- | --- | +| 财运, 买卖, 失物 | 妻财 | the 财 line is what the querent can actually get hold of | +| 事业功名, 官司 | 官鬼 | 官鬼 is the post, the boss, the legal action; if you overcome 官鬼 you win | +| 学业文书, 房产车契 | 父母 | 父母 is documents, grades, licences, contracts | +| 感情, 婚恋 | 妻财 (male querent), 官鬼 (female querent) | 《增删卜易》: 男测婚以财为用,女测婚以官为用 | +| 疾病 | 官鬼 for the illness, 子孙 for the remedy | 子孙 overcomes 官鬼; a strong 子孙 means the illness recedes | +| 出行寻物 | 妻财 | the thing sought counts as 财; to seek a *person* you pick by the relationship, which this package will not guess for you | + +Two deliberate restraints: + +- **婚恋 is gender-dependent and this package will not guess the querent's gender**, so both 妻财 + and 官鬼 are reported and the querent picks the one that applies. Picking for them would be + deciding their gender for them. +- **占病 takes two lines** (the illness and the remedy) and likewise reports both without choosing + between them. Choosing would be reading their illness for them. + +The line-picking order is deliberately reduced to two steps: **a moving line first, otherwise the one +nearest the 世爻**. Transmitted practice is finer — if both move take the stronger one, if both are +still take the stronger or the one at 世/应 — and it also weighs the day branch, the void, and the +tomb. This package has none of those inputs, so it does not invent a rule that looks complete and +cannot be checked. "Nearest" is the plain distance in line positions. + +When no 用神 appears on the hexagram at all, tradition takes the **伏神** — and that is a real +rule with a source, so this package follows it. 《增删卜易·飞伏神章第二十八》: + +> 若用神不现,即以日月为用神,倘日月非用神者,则于本宫首卦寻之,因本宫首卦,父子财官六亲 +> 俱全之故耳。 + +The position rule that follows: find that 六亲 in the **palace's first hexagram**, and it hides +under the same line of the hexagram you are reading. Whatever sits on that line is the **飞神**. +The book works two examples and this package reproduces both to the character — 天风姤 with 妻财 +hidden under 亥水, and 天山遁 with 子孙 hidden under 辰土. + +How the two relate is named four ways, and each carries its own meaning: + +| Relation | Reading | +| --- | --- | +| 飞来生伏 | the line on top feeds the one below; favourable | +| 伏去生飞 | the hidden one spends itself upward; effortful, slow return | +| 伏来克飞 | the hidden one kicks the cover aside; sudden, and mostly bad | +| 飞来克伏 | the line on top pins the one below; suppressed, cannot come out | + +Whether it can **emerge** is the follow-up question. 《增删卜易》 lists six conditions for usefulness +and five for never emerging, and this package now checks **all of them** — 旬空, 月破 and 入墓 were +added once their tables were in hand, so nothing here is left unsaid. What comes out is always +出得来 or 出不来: 旺衰 runs 旺相休囚死 with no sixth state, so 旺相 always lands in the first list +and 休囚死 always in the second, and the two between them cover every case. + +On the diagram the 用神 line carries a **solid red badge**, against the outlined 世/应 boxes; a +伏神 sits under its line as smaller dashed text reading 「伏 丙子水妻财」. + +**元神 · 忌神 · 仇神 — the circle around the 用神** + +Picking the 用神 is only the first step. 《增删卜易》卷之一·用神元神忌神仇神章第九 gives the rest +with a worked example, and the reading follows it clause by clause: + +> 元神者,生用神之爻,即为元神。忌神者,克用神之爻也,即为忌神。仇神者,克制元神不能生用神, +> 反生忌神而克害用神,即为仇神。假令金为用神,生金者土也,土为元神;克金者火也,火为忌神; +> 克土生火者木也,木为仇神。余仿此。 + +All three sit in this hexagram's six lines, none of them is borrowed from outside: + +| | Which line | Reads as | +| --- | --- | --- | +| 元神 | the line that 生 the 用神 | helps feed the 用神 | +| 忌神 | the line that 克 the 用神 | strikes the 用神 | +| 仇神 | the line that 克 the 元神 | indirect — see below | + +野鹤 says what to look at next, and the reading reports exactly that per line — which 爻, 动 or 静, +and its 旺衰 under the month: + +> 既得用神,須看旺衰否?有元神動而生扶否?有忌神動而克害否? + +**仇神 does not strike the 用神 directly**, and the wording is careful about that: it pins the +元神 so the 元神 cannot feed the 用神, and it feeds the 忌神 instead — help on both counts. Saying +「仇神克用神」 would be a misreading of how the mechanism works. All three are fixed test +invariants: the 仇神 must 反生忌神 and must not 生用神, in all five cases. + +A line the hexagram simply does not have is reported as such — 六爻 carry eight 地支 and the five +elements rarely fill out, so the reading says 「本卦六爻里没有这一行」 instead of borrowing one. +The circle exists only when a single 用神 has been settled: a 用神 that went to the 伏神 is off the +board, and 婚恋's two candidates are two questions at once, so neither gets a circle. + +And one line from the same chapter this package takes to heart: + +> 勿以仇神即仇人也 + +The 仇神 is a position in the five-element scheme, not a person in the reading. The source is blunt +about it: whoever it calls the enemy is someone else — an 应爻 that 克 the 世爻. + +This also fills in something the 回头克 rule had been quoting without computing: + +> 凡遇回頭剋者,徹底剋盡,原用二神遇之則凶,忌仇二神遇之反吉也 + +Once the circle is known, the reading can say which of the four the 回头克 actually lands on — 凶 on +the 用神, 不作凶论 on a 忌神 or 仇神, and for 元神, where the text says nothing, it says so rather +than filling the gap. Each case has its own test: a 回头克 falling on the 元神 must *not* come out +judged, and one with no circle at all must not borrow the clause. + +On the diagram each of the three sits on its own line as a small outlined tag reading 元, 忌 or 仇; +忌神 gets the red outline, since it is the one actually striking the 用神, while 元神 and 仇神 stay +in the muted tone of the 伏神. + +**暗动 · 日破 — the still line that starts moving today** + +The circle above leaves a question open, and the same chapter that posed it answers it four +chapters later. After listing 元神, 忌神 and 仇神, 野鹤 asks: + +> 既得用神,須看旺衰否?有元神動而生扶否?有忌神動而克害否? + +"Is the 元神 moving to feed it? Is the 忌神 moving to strike it?" — and the answer needs a third +kind of movement, from 《增删卜易》卷一·暗动章第二十二: + +> 靜爻旺相日辰沖之爲暗動,靜爻休囚日辰沖之爲破。 + +One sentence, two halves. A **still** line clashed by the day's branch is 暗动 when it is 旺 or 相, +and 日破 when it is 休, 囚 or 死. Not an edge case: across the sixty-four hexagrams over all twelve +months and days and every combination of moving lines, about **24%** of readings have at least one +such line — 10% 暗动 and 14% 日破 — and 42% of all clashed still lines are 暗动 rather than 日破. +Until now this package treated every still line as simply still, +so 「忌神暗动克害用神」 and 「元神暗动生扶用神」 — the two things the chapter is actually about — +could never be reported at all. + +| | Source | How it is worked out | +| --- | --- | --- | +| 暗动 | 《增删卜易·暗动章第二十二》 | Still line, the day's branch clashes it, 于月建旺 or 相. | +| 日破 | same chapter, second half | Still line, the day's branch clashes it, 于月建休, 囚 or 死. | + +The chapter number is **第二十二**, per the volume-one table of contents and the transmitted page +heading. Some secondary sites label it 025 — that is the whole-book running order, not the chapter +number within the volume, and is not followed here. + +Only **still** lines qualify. A clashed line that is already moving is 冲散, which belongs to +动散章第二十三 and is covered in the next section; the two are kept apart, and a test pins that a +clashed line drops out of both lists the moment it starts moving. + +旺衰 has five states and the split is exhaustive, so there is no third case and no "cannot tell" +fallback. Two structural facts fall out and are checked rather than assumed: the day's branch can +clash only one branch, and when two lines share a 纳支 (22 of the 64 hexagrams do, e.g. 水雷屯's +first and top lines are both 子) they share the element, hence the 旺衰, hence land on the **same** +side of the split — a reading never has both 暗动 and 日破. + +**Three places this package deliberately does not follow the looser practice.** The chapter's own +worked example is the reason; the reading is stricter than the example: + +> 即如寅月乙未日占女痘得坤之師卦…二爻巳火動而克金,得未日沖動丑土,土動生金 + +That is 坤为地 with the second line moving, 巳火 striking the 用神 酉金子孙, and the 未 day +stirring 四爻丑土 so that earth feeds the metal. But 丑土 in 寅月 is not 旺相 — it is 死 — so by the +chapter's own wording this line is 日破, and the example uses it as a rescue anyway. What rescues it +in practice is that 巳火 is a moving line and 火生土, i.e. the *static* line has 动爻 support. That +is a reasonable refinement and later writers adopt it, but it is not what the chapter says, and +patching an example into the definition would be changing a 通例 by a 个例. This package keeps the +stated definition and says so here. The test reproduces the whole example step by step — 坤为地, +世 on the top line, 应 on the third, 二爻 moving to 地水师, 未 clashing 丑, 丑土 in 寅月 falling to +死 — and pins it as 日破, so that if someone later widens the rule to match the example, the test +tells them plainly that the definition and the example now disagree. + +The 忌 half carries a variant. The received text reads 「若遇忌神克害用神」 without 暗动; most later +commentators read 「忌神暗动克害用神」. This package takes the reading **with** 暗动, because the 喜 +half right above it does say 暗动 — 「得元神暗動以相生」 — and the chapter is titled 暗动 and opens +by saying 「暗動者有喜有忌」. One further half-sentence in the same passage, 「忌神明動於卦中,得 +元神暗動而生用神」, is self-contradictory: a 忌神 克 the 用神 by definition and cannot feed it. The +transmitted text and most later editions leave it uncorrected, and nothing here is built on it. + +The reading reports the whole chain: which lines are 暗动, which is which of 元 / 忌 / 仇, and then +the chapter's own two verdicts — 元神 暗动 feeding the 用神 is 喜, 忌神 暗动 striking it is 忌. Two +things it refuses to do. If the 用神 is not settled there is no circle and no verdict is attached. +And both verdicts are written under a 「用神休囚」 premise, so when the 用神 is 旺 or 相 the reading +says that premise does not hold instead of applying them anyway. A 仇神 暗动 gets neither: the +chapter divides 喜忌 between 元神 and 忌神 only, so it says the text does not cover that case. + +野鹤 refutes the common saying in the same chapter, and the reading quotes both halves: + +> 占以暗動福來而不知,禍來而不覺。 + +> 吉凶之應於動,有急緩之應,則緩非此論,何當不知不覺,報應亦非緩也。 + +暗动 is therefore not read as slow. On the diagram 暗动 is a small 暗 mark and 日破 a small 日破, +in the same slot as 空 / 破 / 墓. 日破 takes the red of the 月破 it belongs with; 暗动 stays muted, +because its 吉凶 depends on whether the line happens to be the 元神's or the 忌神's, and a single +cell in the diagram does not know that. The 野鹤 exchange above is printed only when a 暗动 is +actually found — it answers the saying about 暗动 being slow, so quoting it in a reading that has +only a 冲散 would be off-topic. + +**冲散 — the moving line the day clashes** + +The very next chapter, 《增删卜易》卷一·动散章第二十三, opens with the rule in one line: + +> 占以日辰而沖動爻,謂之沖散。 + +A **moving** line clashed by the day's branch is 冲散. It is the third way the day's clash lands, and +it is commoner than the other two put together: across the same sample, **24%** of readings have one, +against 10% 暗动 and 14% 日破. At 1.05 lines per reading, and never more than two. + +The three are kept strictly apart. A line is sorted by whether it is moving first and by 旺衰 second, +so no line can ever land in two of them, and a clashed moving line is never also called 暗动 or 日破. +暗动 and 日破 are additionally mutually exclusive on their own, for a reason already given: two lines +sharing a 纳支 share an element, hence a 旺衰, hence the same side of the split. + +**The chapter's own conclusion is that it does not disperse.** 野鹤 does not simply define the term, +he then reports testing it: + +> 予屢試之,旺相者沖之不散,有气者沖之不散,休囚者間有沖散,亦千百中之一二也。 + +旺 or 相 lines survive the clash; 休 or 囚 lines mostly do. Even those only scatter "once in a +thousand or two". He closes by pointing at where the omen actually lives: 「神兆機於動,動必有因」 — +the mechanism sits in the movement, and movement has a cause. So the reading **reports the fact and +does not turn it into a verdict**. Where a clashed moving line is 旺 or 相 it says so and quotes 「旺相者沖之不散」; in no case does it call the line weak today and pronounce bad news, and a test pins that — mutating the sentence into 「该爻今日无力,凶」 turns the suite red. + +**The exemption that came from elsewhere.** 《易冒·日冲章》 narrows the rule with four characters: + +> 如動爻遇日辰相沖,苟非月建,則謂之散。 + +「苟非月建」 exempts the month's build: a moving line clashed by the **month**, not the day, is not +冲散 — that case is 月破 and already has its own section. So 冲散 is decided by the day's branch +alone. A test holds exactly that, and holds it in the form that could fail: across all twelve months +under one day's branch, the set of 冲散 lines never changes, and a line clashed only by the 月建 never +appears in it. + +**One part of the chapter is not implemented, deliberately.** 「有气者沖之不散」 names a third state +— 有气 — and the chapter does not say which of the five 旺衰 states it corresponds to. 《四时旺相章》 +has a clause giving the branch clashed by the 月建 the weaker 休 or 囚 and leaving the others with +their 余气, but it does not say where the 余气 lands. Guessing would be inventing a 通例, so only the +旺相 and 休囚 halves are read. 爻动 clashing another 爻 is a separate judgement about which of the two +is stronger and is likewise not attempted. + +On the diagram 冲散 is a small 冲散 mark in the same slot as 空 / 破 / 墓 / 暗 / 日破, and it stays +muted with a tooltip giving the rule — no red, because the chapter's finding is that the line does +**not** come apart. Inside the 用神 circle the state is a fourth tier, 「动而逢日冲」, sitting beside +动, 暗动 and 静: earlier the reading only distinguished moving from still, so a 用神 that was itself +being clashed apart was reported as simply 动. The two sections that mention it — the 用神 circle and +the 用神 line of the 断语 — now take their wording from one shared helper, so they cannot drift apart. + +**六冲 · 六合 — the clash that belongs to the whole hexagram** + +Everything above is a clash the **day's branch** brings to a single line. The chapter right after +动散章第二十三 is about a different kind entirely: a clash built into the hexagram itself. +《增删卜易》卷一·六冲章第二十 opens by naming the six branches that clash, then counts the ways: + +> 子午相冲、丑未相冲、寅申相冲、卯酉相冲、辰戌相冲、巳亥相冲。相冲之法有六:日月冲爻者一也, +> 卦逢六冲者二也,六合卦变六冲者三也,冲变六冲者四也。动爻变冲者五也,爻与爻冲者六也。 + +The first way is the day's and month's branches against a line — already computed, line by line, in +暗动章第二十二 and 动散章第二十三. The other five are what this section adds. 「冲变六冲」 is how +the base text reads the fourth; 明天机一系 writes it out as 「六冲卦变六冲」, meaning the same thing +(the hexagram that comes out is also a 六冲卦), and this package uses the expanded wording. + +**The pairing is 初四、二五、三六 — and getting it wrong finds nothing.** 纳甲 loads the inner three +branches at positions 1–3 and the outer three at 4–6 from an offset of one, so 乾 carries 子寅辰 inside +and 午申戌 outside: the counterpart of a line is three places away, not at the same height. Pair them +the naive way and not one of the sixty-four hexagrams comes out 六冲. With the right pairing there are +exactly **ten** 六冲卦 and **eight** 六合卦, matching the transmitted lists name for name: + +| | which | names | +| --- | --- | --- | +| 六冲卦 | eight 八纯卦, plus the two 乾/震 crosses | 乾为天、坤为地、天雷无妄、坎为水、离为火、雷天大壮、震为雷、艮为山、巽为风、兑为泽 | +| 六合卦 | — | 地天泰、天地否、雷地豫、山火贲、地雷复、泽水困、火山旅、水泽节 | + +乾 and 震 load the same branches, which is why 天雷无妄 and 雷天大壮 are 六冲 too. The lists are +re-derived from 纳甲 and checked against these names when the module loads, so a wrong 纳甲 table +throws rather than quietly reporting a different set. + +**One pair proves three.** The received rule says 「这三组,只要有一组相冲,其他两组必定相冲, +一看就知」. That is not an observation, it is forced: the six branches are one sequence translated, +six clash partners split the twelve into disjoint pairs, and each branch has exactly one. So one hit +means three. A test holds that for all sixty-four hexagrams, and the module-load check refuses to +start if it ever stops holding. + +**爻与爻冲 is not 六冲卦.** Any two lines clashing inside one hexagram happens in 30 of the 64 — twenty +of them are not 六冲卦 at all. A 六冲卦 is the whole structure, all three pairs; an incidental clash +is a local fact. Conflating them would call a third of the hexagrams 六冲. So the reading reports +those pairs without ever raising them to that, and it does not open a section for them alone. + +**The chapter's verdicts, and what this package refuses to decide.** 冲 is 散, and 散 helps a bad +matter and hurts a good one — but the chapter immediately qualifies itself with 「亦必兼用神而言, +用神若旺,虽冲不碍;用神失陷,凶而又凶」. That half is decidable and the reading applies it: with the +用神 settled it says 旺 or 相 leaves the clash harmless, 休 囚 死 makes it harmful, and if the 用神 is +not settled it says the layer does not apply. Two halves are quoted and not applied. Whether the +matter asked about is a 吉事 or a 凶事 is the querent's own framing, not something a question string +carries, so both halves are put on the table and neither is chosen. And 「近病逢冲即愈,久病逢冲则 +死」 turns on whether an illness is recent or long-standing, which only the person asking knows, so +占病 gets the rule quoted and no side picked. The one conditional the reading *can* join up is the +last half of 「惟占官非、盗贼、结绝事者宜之」: when the question has been recognised as 官讼是非, +that half applies and the reading says so. When it has not, it says the matter is not of that kind +rather than re-judging it. + +The chapter also writes the pattern up hard in both directions — 六合变六冲 as 「先合后离、先亲后疏、 +先浓后淡、始荣终悴、得而复失、成而后败」, and 六冲变六冲 as 「内外变动,交相冲击,必主上下不和, +至亲反目,彼此怀奸,始终不就」 — and then in the same breath refuses to decide the hexagram: + +> 古以六冲卦,诸占不吉。予屡试之,用神失陷,实不为吉;用若得地,须以用神断之。 + +So a 六冲卦 on its own is not bad news, and this package does not colour it as such: on the diagram +the 六冲 and 六合 marks are thin and muted, never red. + +**The pairing, drawn.** 「初四、二五、三六」 is the one rule in this chapter that is easier to see +than to read. Pair the lines at the same height and not one of the sixty-four hexagrams comes out +六冲; pair them three apart and the ten and the eight fall out on their own. So the diagram draws +it: three thin arcs in the margin, each joining one pair, bowing away from the hexagram. They show +the thing the prose keeps having to insist on — the two branches that come together are always +three rows apart, never side by side. + +The arcs sit in the gutter between the two columns, not in the hexagram. That is deliberate and it +cost something to find: the bars are a `1fr` track, so any width reserved for the arcs comes straight +out of them, and on the 变卦 the row carrying 回头生 + 应 + 六亲 + 干支 is already the widest — one +reserved column wide enough for the arcs and that row's bars collapse to nothing, at which point the +line no longer reads as 阴 or 阳 at all. Putting them in the gutter costs the diagram nothing. + +There is no label on the arcs. The three pairs are already spelled out in the right-hand 卦体冲合 +row, and a second copy to check against the first is a second thing to get wrong. Each arc carries a +`` naming its two branches, which is what a screen reader and anyone reading the source get. + +The arcs appear on eighteen hexagrams and on no others, because the three pairs come out either all +clashing or all not: the ten 六冲 and the eight 六合, and the remaining forty-six draw nothing at all. +There is no hexagram that clashes on one pair only, so there is no case for drawing a single arc — +and the module-load check now holds that, not just 「one pair implies three」. + +**爻之合 — 合起, 合绊, 合好, 化扶** + +The 六冲/六合 section above is about the hexagram as a whole. The 六合 chapter has a second layer +that lands on single lines, which this package had not implemented until now. + +The chapter states 「相合法有六」, and the six divide in two: the last three (卦逢六合, 六冲变六合, +六合变六合) are whole-hexagram structures, already computed and already drawn as the three arcs +beside the hexagram. The first three (日月合爻, 爻与爻合, 爻动化合) land line by line. +「爻之合者,静而逢合,谓之合起;动而逢合,谓之合绊;爻与爻合谓之合好,爻动化合谓之化扶」 — +the four names follow from the line's own motion and from where the combination comes from: + +| Name | When | The book's words | +| --- | --- | --- | +| 合起 | a still line combines with the day's or the month's branch | 「得合而起,即使爻值休囚亦有旺相之意」 | +| 合绊 | a moving line combines with the day's or the month's branch | 「动逢合而绊住,反不能动之意」 | +| 合好 | two lines combine, and **both are moving** | 「乃得他来合我,与我和好相助之意」 | +| 化扶 | a moving line's transformed line turns back to combine with it | 「得他扶助之意」 | + +合好 carries an explicit limit: **「但有一爻不动,亦不为合」**. So a still line combining with a +moving one is not counted as 合好. A later sentence in the same chapter, 「爻静或与日月动爻合者」, +is punctuated two ways; read as 「与日月、动爻合者」 it would let a still line combine with a moving +one, which contradicts the sentence above. This package takes the reading that can be checked and +leaves the conflicting branch out, with the reasoning recorded in the code rather than papered over. + +合好 only takes pairs other than 初四, 二五, 三六 — those three belong to 卦逢六合 and are already +drawn beside the hexagram, and listing them again here would be a second account to reconcile. Twenty +of the sixty-four have such a further pair (雷火丰 pairs its first 卯 with its top 戌, 山泽损 pairs +二四 and 三五), and 合好 is what fires on those. + +**This layer does not decide 吉凶, and that is the book drawing the line itself.** Three places in +the chapter say so: 「然必用神有气相宜,用若失陷无益」, 「用神受克,六合有何益哉」, and at the very +end 「宜合吉,不宜合凶」. So the reading reports the relation and the name only; the verdict stays +with 用神 vitality. The 「合」 mark on the hexagram is muted like 暗动 and 冲散 and never takes 朱砂 — +colour should not speak for something whose 吉凶 has not been settled. + +Measured over 365 days × 256 toss patterns (93,440 readings): about **81%** produce this section; +合绊 52%, 化扶 30%, 合起 29%, 合好 16%, averaging 1.97 combined lines per reading and at most all +six. 合起 and 合绊 both appear in the same reading about 12% of the time (one still line lifted while +another moving line is held). + +**爻之刑 — 犯刑** + +三刑章第二十一: 「寅刑巳、巳刑申、子刑卯、卯刑午、丑戌相刑、未辰相刑。又云:辰午酉亥谓之自刑。」 + +**The base text's six, not the fate-reading set of eight.** They differ in two substantive places: the +base text has 卯刑午, not 卯刑子, and it has 未辰相刑 as one entry rather than splitting it into +未刑丑 and 戌刑未. The eight touch 48 of the sixty-four; the six touch 28. Merging both sets into +fourteen would leave 「how many lines are punished」 without an answer, so the variant reading is recorded +here and kept out of the table. + +**Punishment has a direction.** 丑刑戌 comes out on twelve hexagrams, 卯刑午 on eight. The direction is +carried through as it stands — who punishes whom — and never folded into a flat 「this line is punished」, +because folding it away loses the only thing that says who acted. + +**Of the four self-punishing branches, 辰 can never come out on the line-to-line path.** That is not an +oversight but a structural fact: 辰 only sits in the three inner lines (乾 carries 子寅辰, 坎 carries +寅辰午, 艮 carries 辰午申), and a hexagram has one lower trigram, so at most one 辰 appears and +本支见本支 cannot be formed. 午, 酉 and 亥 all come out (8, 6 and 8 hexagrams). The 辰 branch is not dead +either — when the month's branch is 辰 and a 辰 line is present, 「月建自刑辰爻」 holds, and 4.2% of +readings over 365 days take that path. The load-time check pins 「self-punishing 辰 is always 0 on the +line-to-line path」 so nobody later adds it back as an apparent omission. + +All three paths are reported: line against line, 本支见本支 self-punishment, and the day or month branch +against a line. Self-punishment is symmetric, so a pair is reported once; collecting it from both +ends would put 「二爻亥自刑四爻」 and 「四爻亥自刑二爻」 side by side, which reads as two separate +things. The six directed pairs are unaffected. The book's own case reproduces step by step — 寅月申日, 风火家人 changing to 离卦: the +month branch 寅 punishes the fifth line's 巳, and the day branch 申 is punished by that same 巳. Both +land on one line, which is the 子孙 line. + +**This layer does not decide 吉凶, and here the book draws the line itself.** 「夫三刑者,予屡试之, +或因用神休囚又兼他爻犯之,刑者则见凶,而独犯三刑得验者少,占过数十年只验得一卦。」 Ye He tried it +himself for decades and it verified on a single reading. So the reading reports who punishes whom and +then checks the two preconditions — 用神休囚 and 又兼他爻犯之 — one by one, saying plainly whether each +holds; the verdict stays with 用神 vitality. The 「刑」 mark on the hexagram is muted, like 「合」, and +never takes 朱砂. + +Measured over 19,800 readings by the numbers method: about **90%** carry this section. A separate sweep of +the sixty-four hexagrams across 365 days (23,260 readings) gives 90.7%, and the two agree. Of those, 58% +come from the line-to-line path and 80% from the day/month path, averaging 1.05 punished lines per +reading and at most three. + +**What is deliberately not here.** 卦身 and 世身 appear on most traditional charts, and this package +draws neither. Not an oversight: 《增删卜易》 is the base text here, and removing them is stated as +one of the book's own features — 「删除卦身世身、星煞本命,使人无歧路之虞」. 《卜筮全书》 and +《卜筮正宗》 record the terms without using them, 韩艺's 连三易 leans on 世身 heavily, and later +writers split three ways. A package that follows 《增删卜易》 as its spine and adds a layer the spine +deleted would be arguing with its own source. The rules are recorded here for anyone who wants them +— 阳世从初爻起子、阴世起午,数至世爻那一支便是卦身; 世身按世爻地支定爻位(子午居初、丑未居二、 +寅申居三、卯酉居四、辰戌居五、巳亥居六)— but they are not drawn and not used to judge anything. + +**反伏与卦变 — the two ends of the same thing turn over** + +反伏章第二十五 puts two things side by side from its first line, but their conditions do not match, so +this package reports them as two separate branches rather than folding them into one: + +> 卦有卦變,爻有爻變。卦變者內外動而反伏者同一卦也。如乾卦變坤卦。爻變者內外爻動而反伏者,非同一卦也。 +> 如升之觀是也。又有外卦反伏而內卦不動者,如觀之坤是也。又有內卦反伏而外卦不動者如巽之觀是也 + +**The 反伏 branch: the inner or outer trigram's 纳支 has been replaced, as a set, by the one that clashes +with it position by position.** All three of the book's cases fit that: 观之坤 moves the fifth and sixth +lines, so only the outer set changed; 巽之观 moves the second and third, so only the inner set did; 升之观 +moves the second, third, fifth and sixth, so both did. In every case the set it changed to clashes with the +original position by position — 观's outer 巽 carries 未巳卯 and 坤's carries 丑亥酉, and each of the three +pairs really is a 六冲 pair. The package judges inner and outer separately, so 「inner only」, 「outer only」 +and 「both」 stay distinguishable instead of being flattened into one flat 「this hexagram is 反伏」. +Across the sixty-four hexagrams and the sixty-three non-empty moving patterns — 4,032 combinations — this +branch lands on 252 (4 with both, 124 inner only, 124 outer only). + +**The 卦变 branch: all six lines move, and both the original and the changed hexagram are 八纯卦.** The +乾变坤 in the book's first line clashes in not a single position (子 against 未, 寅 against 巳, 辰 against 卯), +so it is not in the branch above — what it rests on is 「同一卦」: 乾坤, 坎离, 震巽, 艮兑, the four +complementary pairs. The 「all six」 condition cannot be dropped: 乾 moving only the first and fourth also +reaches 巽为风, and both ends are 八纯卦, but nothing like the book's 「內外動」 happened. A full sweep puts +this branch at eight, and its overlap with the 反伏 branch is **zero** — fold them into one and 乾变坤 is +either dropped or the 反伏 criterion has to be loosened until it can no longer be checked. The load-time +self-check pins both the zero overlap and the count (252 + 8 = 260). + +**Each of the three situations has its own line, and this package does not pick one for you.** When both +the inner and the outer trigram are 反伏, both of the book's lines — 「內卦反伏,我亂他定」 and +「外卦反伏,他亂我定」 — apply, and the reading says so rather than reciting one. + +**This layer does not decide 吉凶, and here the book draws the line itself.** The closing line leaves no +room: + +> 反伏卦用神旺相不變沖克者則反復,事之必成,第恐用神而化回頭之沖克者,卽是卦變大凶之象。 + +Both preconditions are stated in terms of the 用神, so the reading only checks them: 用神旺相 is checked +against the 用神's vitality in the month's branch, and 用神化回头冲克 is checked by whether the 用神 line +moves at all and whether the line it turns into 克s it back. Each is reported plainly as holding or not, and +**「事之必成」 is never stated as an unconditional verdict**. When no 用神 can be taken, the first +precondition cannot be checked at all, and the reading says which layer is missing rather than talking past it. + +**Only the 分占 lines that match the topic asked are taken from the chapter's ten.** 占功名, 占财物, +占墳墓宅捨, 占婚姻, 占疾病, 占盗贼官非 and 占出行 each land on one of this package's topics (恋爱 and 婚嫁 +share 占婚姻), and the 占彼此 line is quoted separately for the inner and the outer case. 占天时 and 占行人 +have no matching topic here, and rather than borrow another 占法 to fill the gap the reading says it cannot +connect them. + +Measured by hand-tossed coins: about **6.4%** of readings carry this section — 内卦 47.7%, 外卦 47.7%, +both 1.5%, 卦变 3.1%. **时间起卦, 每日一卦 and 数字起卦 never produce it**: not an omission, but a +consequence of the method — all three move exactly one line by the book's own rule (the moving line is the +total divided by six), while 反伏 needs at least two moving lines and 卦变 needs all six, so the conditions +cannot meet. Zero hits in 9,125 casts; 6.4% across the sixty-four hexagrams × 63 moving patterns × 31 sampled +days (124,992 readings). + +**What is deliberately not here: a 反伏 mark on the hexagram.** 空, 破, 墓, 暗, 日破, 冲, 合 and 刑 all hang +on a single line; 反伏 and 卦变 cannot, because they describe the relation between the original and the +changed hexagram rather than any one line's situation, and pinning them to a line would point at the wrong +place. They reach the reading and the MCP header only. + +**六神 — what the day's stem says about the mood** + +The sixth column a 六爻 chart has always carried. 《卜筮全书·卷之一·启蒙节要》: + +> 甲乙起青龍,丙丁起朱雀,戊日起勾陳,己日起螣蛇,庚辛起白虎,壬癸起玄武。(俱從下起至上。) + +The day stem fixes which god sits on the **first line**; from there they run upward in one fixed +order, 青龙 → 朱雀 → 勾陈 → 螣蛇 → 白虎 → 玄武, wrapping around. The song is stored verbatim and +checked cell by cell against the six-row table that follows it — thirty-two cells, since 甲乙, 丙丁, +庚辛 and 壬癸 are paired. The two worked 乾为天 charts in the same chapter are checked line by line: +on a 甲子 day 子水子孙 carries 青龙, on a 戊子 day it carries 勾陈. + +| God | Element | Stands for | +| --- | --- | --- | +| 青龙 | 木 | 喜庆、喜事、贵人、酒色、正直 | +| 朱雀 | 火 | 口舌、文书、消息、是非、诉讼 | +| 勾陈 | 土 | 田土、房产、牵连、迟滞、牢狱 | +| 螣蛇 | 火 | 怪异、虚惊、缠绕、噩梦、欺诈 | +| 白虎 | 金 | 凶险、血光、伤病、丧事、威猛 | +| 玄武 | 水 | 暗昧、盗贼、隐私、暧昧、欺瞒 | + +螣蛇's element is disputed — fire in most circulating editions, yin earth in others. This package +takes **fire** and says so, because taking earth would make it identical to 勾陈, and 虚惊 and +迟滞 are not the same thing. + +One line this package will not cross, and the reading says so out loud every time it names a god: + +> 吉凶全憑五行生克,情態方看六神吉凶。 + +The gods **do not take part in 生克 and do not move the verdict**. They say what kind of matter +this is. A test casts the same hexagram across twenty-eight different days — the god under the first +line changes hands several times over — and requires the verdict not to move by so much as a +character. + +**化爻 · 变出之爻 — where a moving line goes** + +The last layer of 京房: a moving line does not stay itself, it turns into another 纳支 line in the +变卦. What that line is to the one it came from is the whole of 化爻. + +| | What it is | Reads as | +| --- | --- | --- | +| 回头生 | 变爻生本爻 | 吉 | +| 回头克 | 变爻克本爻 | 凶 | +| 化泄 | 本爻生变爻 | stated, no verdict | +| 化耗 | 本爻克变爻 | stated, no verdict | +| 化比和 | 同行 | stated, no verdict | + +The two names are fixed by quoted text, and the two directions matter: + +> 巽木变坎水,谓之化生,水回头以生木也,即以吉断。 + +> 震木变乾金,谓之化克,金回头以克木也,即以凶推。 + +Both watch one direction only — **变爻 to 本爻**. 《卜筮正宗·十八问答第二问》 then spells out all +five 回头克 cases and they land exactly on the five element pairs where 变爻克本爻: 土爻动变木、 +木爻动变金、金爻动变火、火爻动变水、水爻动变土. That list is stored as `HUI_TOU_KE_PAIRS` and +checked against the 生克 table when the module loads, so the definition cannot drift from the code. +The same chapter adds the clause that makes 回头克 more than a flat verdict: + +> 凡遇回頭剋者,徹底剋盡,原用二神遇之則凶,忌仇二神遇之反吉也 + +so the reading always says it — a 回头克 landing on the 用神 is bad, landing on a 忌神 turns +auspicious. The other three relations get **no** tone: 《增删卜易》 names them but assigns no +吉凶, and inventing one would be making it up. + +**进退神** is the branch-level companion, from 《增删卜易·进退神章第二十九》: + +> 进神:亥化子,寅化卯,巳化午,申化酉,丑化辰,辰化未,未化戌,戍化丑。 +> 退神:子化亥,卯化寅,午化巳,酉化申,辰化丑,未化辰,戍化未,丑化戍。 + +Sixteen pairs, two ways, each pair same-element, each the other's reverse. Four pairs are earth +(丑→辰→未→戌→丑, one step along the ring each way) — that is the 歌诀 as written, not a slip. +Pairs not in the 歌诀 are simply not judged. 化空 and 化墓 on the changed line are reported as +fact, with no verdict attached: 野鹤's emptiness rules are about the moving line, and no text +makes a changed line's own emptiness good or bad. + +One rule bounds the whole layer, and the reading quotes it before giving any of it: + +> 夫變出之爻,能生克沖合本位之動爻,不能生克他爻,而他爻與本位之動爻,亦不能生克變爻。 + +A changed line is weighed **only against the moving line it came from** — not against the other +lines, and not against 世 and 应. Related: the changed line is a *still* line in the 变卦, so it is +never credited with 发动 or 得动爻生扶; it occupies the same position as the moving line, and +mistaking one for the other silently turns a true 真空 into a 假空. + +**旬空 · 月破 · 入墓 — what the day and month do to a line** + +These three sit on top of 京房's 纳甲 and decide whether a line is doing anything today. + +| | Source | How it is worked out | +| --- | --- | --- | +| 旬空 | 《增删卜易·旬空章第二十六》 | The six-line 歌诀 is stored verbatim and checked line by line against the algorithm. Ten stems pair with twelve branches, so every ten-day 旬 leaves two branches unmatched — those are void. | +| 月破 | 《增删卜易》: 月破者,月建冲爻之谓 | The month branch clashes the line, matching the monthly table 正月申破 through 十二月未破. | +| 入墓 | 《纳甲筮法讲义·生旺墓绝》 | Per element, the 自墓 branch: 金墓丑, 木墓未, 水土墓辰, 火墓戌. | +| 绝地 | — | Not in the output, and not by oversight. 绝 is the branch right after 墓 (金绝寅, 木绝申, 水土绝巳, 火绝亥), but 纳甲 gives each element only two branches — 金申酉, 木寅卯, 水子亥, 火巳午, 土丑辰未戌 — and not one of those 绝 branches falls among them. No line in any of the sixty-four hexagrams can land on one. The 绝 mark was removed rather than left as a label that can never light up; loading `jingfang.mjs` checks the fact and throws if it ever stops holding. | + +野鹤 himself splits void into false and true, and the reading follows his wording clause by clause +rather than inventing a rule of its own: + +> 旺不爲空,動不爲空,有日建動爻生扶者不爲空,動而化空、伏而旺相皆不爲空。月破爲空。 +> 有卦不動爲空,爻反伏而被克爲空,真空爲空,真空卽春土、夏金、秋木、三冬逢火是真空。 + +So **假空** means the void is not to be trusted as emptiness — 旺, 动, 得生扶, 动而化空 or 伏而旺相 +rescue it, and it counts once the 旬 passes or a clashing day arrives. **真空** means genuinely +useless this 旬: 月破, or the element the season voids (earth in spring, metal in summer, wood in +autumn, fire in winter), or a hidden line being struck. The two are kept apart on the diagram: 假空 +shows as 空假, 真空 as 空真. + +One reading is a judgement call, and it is flagged rather than hidden: 野鹤's text reads 「有卦不動 +爲空」, while most later copies read 「有氣無動爲空」 — the difference decides whether a still line +with vitality counts as void. This package takes the **latter**, because 旺不爲空 sits in the very +same passage and would otherwise have nowhere to apply. + +Each line carries a small mark on the diagram — 空, 破, 墓, 暗, 日破, 冲散 — and the right-hand panel names +the 旬 and the month's broken branch outright. + +A line that is 月破 but not 旬空 is 真空 too, on the strength of 「月破爲空」 in the same passage. It +used to come out as 「暂看不出真假,等出旬或逢冲之日再定」, which was wrong twice over: it left the +one ground the text names undecided, and it told the reader to wait for a clashing day when +《月破章》 says the opposite — + +> 雖現於卦,有亦如無;伏於卦中,終難透露。即有日辰之生,亦不能生。 + +A 月破 line is therefore reported as 真空 with 「待出月、逢值再论」 rather than 「逢冲」 — out of the +month and onto its own branch is what helps it; a clash only does it more harm. A 旬空 line without +月破 still gets the full 「等出旬逢值或逢冲再论」, because for that kind of void a clash genuinely does +restore it. + +**卦气 · 当令主卦 — which hexagram holds the month** + +Han-dynasty 易学 assigns twelve hexagrams to the twelve months, called the 十二辟卦: 复 rules 子月 +and 临 rules 丑月, round to 坤 in 亥月. This is not the same as 旺衰应期 above — that one reads how +strong the five elements are in the month, this one reads how the hexagram itself waxes and wanes +with the solar terms. 复 through 乾 are the 息 hexagrams, yang growing from the lowest line upward; +姤 through 坤 are the 消 hexagrams, yin growing the same way. The year turns once on that cycle. + +The reading names the month's governing hexagram and says where the cast hexagram sits in it: on +the same 消长 side means the direction agrees with the season, opposite sides means you are running +against it and should slow down. Only twelve of the sixty-four are 辟卦; the other fifty-two are not +forced into the scheme. + +**The 消长 ring — what that theory looks like** + +The twelve 辟卦 are abstract until you see them. The ring has twelve segments, one hexagram each, +and each segment draws N short bars where N is how many lines that month has gained: 复 grows from +one to 乾's six as yang reaches its height, then folds back and 姤 grows to 坤's six as yin does. +One full turn is a year. The dashed diameters at 子午 and 卯酉 mark the two poles. The current month +is highlighted; if your own hexagram is one of the twelve, a vermilion dot marks where it sits. The +ring sits above the reading — the reading is the conclusion, the ring is the seasonal coordinate +behind it, so the shape comes first and 「当月主卦是哪一卦」 stops being a bare sentence. With +reduced motion on, the current segment stops pulsing. + +**应期 — when it lands** + +Taken from the 类神 when a topic was recognised, otherwise from the 用卦. It surfaces during the +branches where that element 当令, and resolves during the branches where it is 相. 乾金为用卦, so +the reading looks to 申酉 months and days. + +**爻位之象 — what the line's position means** + +初爻 is a beginning with nothing yet showing, 二爻 is near you but still under authority, 三爻 is +the threshold where things turn, 四爻 is the anxious position closest to other people, 五爻 is the +ruler's seat and where benefactors sit, 上爻 is the ending. + +**方所 — directions** + +The 后天八卦 directions of the 体 and 用 trigrams, for lost things and for travel. + +Where the verdict is 吉 but the 用卦 drains the 体卦, the reading adds a caution rather than a +clean yes: 方向可进,力气要省. + +**卦库 — all sixty-four hexagrams** + +Search by name or by upper/lower trigram, then read the 卦辞, 彖传, 象辞, and the 互卦 / 错卦 / 综卦 +cross-references for any hexagram. + +**历法 — the almanac** + +Ganzhi for the year, month, day and hour; the twelve 时辰 with their 黄道/黑道 office; the +建除十二神 day; the 二十四节气 calendar; the nine-day 数九 period; and the twelve zodiac with +harmony and clash relations. + +**卦历 — your own log** + +Castings you save are stored on this machine, with a one-line note you can add before saving, +newest first, up to 500 entries, and can be deleted individually. A casting you gave a question is +titled by that question; one cast without a question is titled by its method (today / at this moment +/ from numbers / tossed by hand). + +**在对话里起卦 — the MCP endpoint** + +This package also offers the Agent an MCP (streamable-http) endpoint at `/mcp/divination`, so it +can cast without the page ever opening. Its three tools are `divination_cast` (cast and +interpret), `divination_hexagram_lookup` (search the sixty-four), and `divination_almanac` +(today's ganzhi almanac). + +**The package itself calls no model and makes no outbound request.** The Agent *is* the model: it +calls this endpoint, then explains the reading in its own words. Keys, billing and context stay in +the session; this package only has to be right. + +For arguments, response shapes, trigger phrases, and troubleshooting, see +[Casting in the conversation (MCP)](#casting-in-the-conversation-mcp) above. + +## Install + +Copy this directory, including the hidden `.minimax-plugin/`, into the MiniMax Code plugins +directory as `chinese-divination/` (`~/.minimax/plugins/chinese-divination/` by default; the root +README's Install section explains where that directory is). Restart MiniMax Code and ask the Agent +to "打开灵签易占". + +## Tested environment + +- A scripted smoke test boots this package's `start(context)` against a throwaway `dataDir` on + loopback and exercises it end to end: the page, all four cast methods, the coin toss, the 64-entry + library, the almanac, history create/read/delete, the same-second id collision that used to make + one delete remove two entries, a deliberately corrupted `readings.json`, and the MCP endpoint's + `initialize` / `tools/list` / all three `tools/call` / notification / unknown-tool / bad-topic + paths. It also asserts that the `Host` and `Origin` guards reject a non-loopback name — driven + through a raw HTTP request, because `fetch` silently drops a caller-supplied `Host` — that `dispose` + really closes the listener and is idempotent, that no log line carries the `dataDir` path or the + operating-system user name, and that the `dataDir` afterwards holds nothing but the reading log. + macOS, Node 22.23.2: all 83 checks pass. +- The suite is run under four timezones spanning UTC-8 to UTC+14 (`America/New_York`, `UTC`, + `Asia/Shanghai`, `Pacific/Kiritimati`) and under `LC_ALL=C LANG=C`. 228 pass, 0 fail in every + combination, so nothing here depends on the machine's clock zone or locale. +- A path audit over the package reports no problems: every path segment is ASCII and matches the + portable-path rule, no segment collides with a Windows reserved device name, every text file is + UTF-8 with no BOM and no CRLF, no two files differ only by case, every relative import resolves to + a file that exists, and the worst-case `dataDir` path is 112 characters — well inside `MAX_PATH`. + +- **Windows and Linux have been run.** All three platforms are exercised, not only reasoned about. + The macOS round is the scripted one described below — the one whose every step is listed here. + On Windows and Linux the package was installed, opened through the Agent, and the four sections + driven, with the coin-toss, history and MCP paths checked along the way. The static checking + stands behind that run and is what the two platform-specific design decisions rest on: every path + is assembled with `node:path` (`join` normalises the forward slashes in + `miniapp/client/index.html` into backslashes on Windows, UNC paths included); no source file + contains a hardcoded separator or drive letter; there is no `__dirname`, which does not exist in + ESM, and `import.meta.url` appears only in the test file as `new URL(relative, import.meta.url)` + plus `fileURLToPath` — the cross-platform-correct ESM form, and outside the runtime payload + anyway; the three on-disk names (`readings.json`, its `.tmp`, and its `.corrupt-<timestamp>`) are + legal Windows names, free of reserved device names and characters; the longest of them reaches + 139 characters in the worst dataDir shape simulated, well inside `MAX_PATH`; the two path literals + in the source match the on-disk spelling character for character, which macOS would have accepted + even if they did not; and every text file is UTF-8 with no BOM and no CRLF. +- **One thing remains unverified on every platform:** the layout below the 760px breakpoint has not + been checked on a real screen. Narrowing the content area is not a substitute — the container + queries below react to block width, not viewport width, so only a real narrow viewport exercises + that path. +- MiniMax Code 3.0.73 on macOS, Node 22. Installed from this directory, opened through the Agent, + page rendered and all four tabs exercised. +- The MCP endpoint's `initialize`, `tools/list`, `tools/call`, and error paths were exercised + locally. +- The type scale and the page layout were raised together, twice, and checked page by page at a + 1524x1304 viewport: casting, reading (the hexagram rows, the four-hexagram derivation, the + twelve-辟卦 growth ring, the eight-palace list), library, almanac (twelve hours, solar terms, + zodiac), and history with a 40-character question saved into it. Nothing wrapped or overflowed, + and the browser console stayed clean. +- The hexagram drawings are pinned by a test on two counts, both of which were once true and + read as a grey smudge: the yin lines may not be painted in `--border-strong` (two tenths of + black, used so that yin would recede), and no line drawing may fall back to a 4-5px hairline. + Yang and yin are told apart by shape — one whole bar against two broken halves — so brightness + has nothing to add, and the main hexagram on the reading page is held to a heavier minimum + than the thumbnails it sits beside. +- The twelve-hour grid is pinned by a test: its column count must divide 12 and the time and + office lines must not wrap, because an `auto-fit` track silently squeezed each cell until + `03:00 - 05:00` broke across two lines and left that row of cards at two different heights. +- Three more layout defects are pinned by tests, all found by looking at the running page rather + than by reasoning about it. The eight-palace roster aligned its items to the top, and the + current hexagram alone carried a border and padding for its seal frame, so that one cell sat + five pixels lower than the other seven; the frame is an outline now, which draws without taking + up room. On the twelve-辟卦 ring the bars reach further out than the labels were hung, so on the + two sectors carrying all six lines the bars fell across the characters — the labels moved out + and the canvas grew from 200 to 232 units square to make room for them. The ring's breathing + animation dipped the current sector to 0.55 opacity, flickering the very bars it was pointing + at out of legibility twice every 2.6 seconds; it now bottoms out at 0.8. +- The reading page is two columns with the right one far longer than the left, so scrolling left a + large blank behind. The title and the four sections are now one pinned top bar — a translucent + blurred ground with a rule under it — and on a wide viewport with enough height the left column + is pinned and offset by the top bar's own height. That height was a number written into `:root`, + and measuring showed it was the 1340px value: narrow the content area to 700px and the title, the + subtitle and the ganzhi pills stop fitting on one line, the top bar grows to about 195px, and the + left column still offsets by 132px — which pushed the 天风姤 heading out from under the bar. The + height is now measured by a `ResizeObserver` and written back; `--topbar-h` is only the first-paint + fallback, and the two places no longer carry a number each. +- The four-hexagram derivation and the eight-palace roster now lay themselves out from their own + width rather than the viewport's. Both were flex-wrap: narrow the content area and the four steps + folded into 3+1, the eight cells into 7+1, and the orphan sat centred on its own as though it had + dropped out of the list. Each block is now a named container (`container-type: inline-size`) and + its column count comes from `@container` — four or two for the derivation, eight or four for the + roster, every count dividing the total so that no row is left one short. Both sit in the right-hand + column, whose width the left column and the page margin decide rather than the viewport; a + viewport query would happily pair a wide screen with a narrow column and pack them unevenly. A + test asserts that no `@media` rule touches either block, which is what keeps the column count from + drifting back onto the viewport. +- Inside the derivation the arrow and the hexagram are one cell now, not two siblings. The + mutual-hexagram note runs to two lines and the other three to one, and the arrow was a centred + flex column, so that one sat half a line below the other three and the four were not on a line. The + note is a centred flex box held at two lines tall, the current hexagram's lone cell spans the whole + row so it centres instead of sitting in the first column, and the line break comes from the `\n` in + the data (`white-space: pre-line`) rather than from wherever `max-width` happened to fall. That + width was 88px, and the longest note segment is seven CJK characters — 91px at the note's 13px — so + that note wrapped to three lines and pushed its arrow off the line with the others. A test measures + every hard-broken segment against the note's width, so a box narrower than the text goes red. +- The page margin is one value now. `.app` padded 32px either side, spending 64px of width on a + 1149px viewport, and that is what pushed the derivation and the roster into wrapping. It is + `--app-pad: clamp(14px, 2.1vw, 32px)`; `.app` uses it and `.topbar` uses its negation, because two + places carrying a number each drift apart the moment the margin moves. The top bar's margin and + padding, the gap between the reading page's two columns, and the left column's width are each + asserted to follow that one value. +- The left column was briefly narrowed to `clamp(232px, 21vw, 366px)` to give the right column more + room, and that was wrong. A hexagram row is `34px + 42px + 1fr + 32px + auto` plus four 12px seams, + so 156px is gone to the fixed parts before the 纳甲 cell — ganzhi, relative, 世 and 应, void and + broken, every one of them `nowrap` — gets its 140-odd px to live in. Under 300px the 纳甲 pushes + through its track into the right column, and the line drawing is squeezed flat at the same time, so + yin and yang stop being readable at a glance. It is `clamp(300px, 26vw, 366px)` now, with the + floor asserted against the width the rows actually need. `minmax(clamp(...), 366px)` was tried + first and is a no-op: with a fixed maximum the track sits at 366px for as long as the content is + wide enough, so the lower bound never comes into play. +- The six tests added for this round were each mutation-tested: twenty-seven mutations broken on + purpose across the two container queries, the four column counts, the cell wrapper, the note box, + the margin's single source, the left column's floor, and the negative case that no viewport query + reaches either block. Each was checked to turn the matching assertion red, and all 27 were caught; + the suite reports 212 passing after the script restored the files. Two traps turned up while + writing the script, and one of them was a real gap in a test. The script had been matching each + mutation's expected assertion against the *test name*, but those expectations are assertion + messages, so a run in which every single mutation behaved correctly reported 26 of 27 unpinned; + it now reads the failure detail instead. And the narrow-screen assertion read + `grid-template-columns: 1fr`, which `1fr 1fr` also matches — the test was vouching for the + two-column case while claiming to check the one-column case. The trailing semicolon is what + separates them now. Restoring the files only in `finally` is a third trap, already noted above: + mutation N then runs against the file mutation 1 broke, which reads as a test that never pinned + anything when it was contamination. Every mutation here restores from a clean baseline first. +- A review of the whole package turned up three defects, all in the history store and the cast id, + all now fixed and pinned. The id was a second-resolution timestamp hashed together with the + hexagram order and the moving lines. Number casting depends only on the two numbers, so casting + the same pair twice inside one second produced byte-identical ids — and the 起卦 button is never + disabled while the casting animation holds for `CASTING_HOLD_MS`, so an ordinary double-click + sends two requests. Both readings get saved, and `remove(id)` filters by id, so deleting one + deleted the other as well: measured, two entries with the same id, one delete, zero left. The id + seed carries a process-local counter now, and the test pins the consequence rather than a + literal string — two readings of the same hexagram in the same second must differ, and deleting + one must leave the other. +- On Windows, renaming a file over an existing one fails outright with `EPERM` or `EACCES` when + another process holds the target open without delete sharing — a virus scanner or the search + indexer, which is not rare. Nothing is half-written; the save simply fails. The rename now + retries with backoff on `EPERM`/`EACCES`/`EBUSY` up to four times (about 400ms in total) and + throws everything else immediately, since retrying a full disk or a read-only mount only holds + the request. The decision is a pure function so it can be tested without provoking a real lock, + and the call site is pinned separately — the predicate alone stays green if someone deletes the + loop that uses it. +- A `readings.json` that cannot be parsed used to throw out of every history endpoint, so a + truncated write, a hand edit or a sync conflict left the history permanently unopenable with no + way to recover. It is now renamed aside to `readings.json.corrupt-<timestamp>` — moved, not + deleted, so a hand-written note can still be recovered — and the history reads as empty. + Separately, `ReadingStore.update()` had no route, no caller and no test; the only field it + produced was `updatedAt`, which `toSummary` copied out to become a field that was always + `undefined`. Both are gone rather than left as a branch that can never run and a key that + implies a note-editing feature the package does not have. `GET /history/:id` stays: its store + method is covered by a test, and it is the natural shape of the resource. +- The fifteen assertions added for these three were each mutation-tested, and all 15 were caught + with the suite reporting 212 passing after the script restored the files. Three of the first + run reported unpinned and all three were the script's fault rather than a loose test: one + expectation named an assertion the mutation tripped *after* an earlier one had already fired, one + mutation deleted the call that an earlier assertion already covered, and one threw out of the + test before reaching the assertion that named the contract — the test now catches the rejection + and folds the cause into the message, so a failure reports the contract that broke rather than a + raw JSON parse error. The suite's own wording is also worth repeating: `assert.match` and + `assert.equal` without a message throw Node's default text, so any keyword-based "did this + mutation pin anything" check can never match. Two of those assertions carry an explicit message + for that reason. +- The 起卦 button had no in-flight guard, and that is the other half of the id defect above. The + cast beat holds for `CASTING_HOLD_MS` — over four seconds — and throughout it every method + button and the 起卦 button stayed clickable, so an ordinary double-click sent two cast requests, + two casting logs typed over each other, and whichever answered last won. The only `disabled` in + that panel belonged to `toss-finish`, and that one encodes a different rule — six tosses + required — not "a cast is in flight". All three casting paths (daily/time, numbers, coins) go + through `cast()`, so that is where the gate now lives: a `casting` flag set synchronously before + the first `await`, and the triggers locked for the duration. +- The restore is the part that is easy to get wrong, so it is pinned explicitly. Unlock puts every + control back to the value it had rather than clearing `disabled` across the board: `toss-finish` + is meant to be disabled whenever fewer than six coins have been thrown, and a blanket re-enable + would leave a "成卦解卦" button that looks available and does nothing. The unlock sits in + `finally`, because a single failed request must not leave the whole casting panel permanently + dead. And `cast()` now returns whether the reading was actually cast: the numbers path clears + its two inputs only on success, since clearing them after a skipped or failed cast throws away + what the user just typed. +- Verified on the running page rather than by reading: double-clicking 时间起卦 produced exactly + one `POST /api/divination/cast` in the network log, and after the cast finished `toss-finish` + still carried `disabled=""` while the method buttons carried none. The disabled state *during* + the four-second beat was not observed directly — the round trip between tool calls is longer than + that window — but the assignment sits in the same synchronous block as the `casting` flag that + the double-click test proves took effect. The twelve assertions covering this were each + mutation-tested and all 12 were caught, the suite reporting 212 passing after the script restored + the files. Two failed the first run for reasons in the script rather than in the test: one + mutation was an empty change that altered no behaviour, and another removed the whole `finally` + block, so it tripped the earlier "there is no finally" assertion rather than the one it aimed at. +- The two coin-toss buttons used to contradict each other. `renderToss()` computed which one should be + disabled from how many tosses had landed, and then the tail of `tossOnce()` hard-wrote an unconditional + re-enable on top of that, wiping the "six tosses is enough, disable 掷钱" it had just computed. Past six + tosses both buttons were therefore live at once: a seventh toss could be thrown — a line that fits into + neither a six-line hexagram nor anything the user can take back, so the reading shifts — and the six + tosses could still be cast. Neither button meant anything. Availability now lives in one place, + `syncTossButtons()`, derived only from `state.tosses.length`, and the in-flight tail calls it to + recompute rather than blanket-enabling. Same lesson as the cast lock above: unlocking has to be computed + from state, never by clearing `disabled` wholesale. +- Two related holes are closed in the same pass. 重来 is held while a toss is in flight — resetting + halfway through would drop the line already on its way into a list that was just emptied, leaving a line + in the hexagram with no visible origin. And the "six tosses, then 重来" path runs no in-flight tail at + all, so its button state has to come from the redraw itself rather than from whatever the previous tail + happened to leave behind. +- This part of the suite **runs** the logic instead of matching source text: `syncTossButtons`, `lockToss`, + `tossOnce` and `renderToss` are lifted out verbatim and driven against a fake DOM and a fake fetch + through seven paths — idle, one to five tosses, the sixth, a seventh, 重来, in-flight, and a failed + request. The defect lived in *who writes `disabled` last*, which source text cannot show. All + thirty-six assertions were mutation-tested and all 15 mutations were caught, the suite reporting 212 + passing after the script restored the files. Seven failed the first run for reasons in the script: the + expect named a later assertion while the test went red on an earlier one carrying the same meaning — + relaxing "成卦解卦 is always enabled" to `> 6`, for instance, trips the idle-state assertion first. + **One of them was a genuinely loose test**: it measured "did a failed toss record a line?" by counting + rows in the hexagram log, but a failed request never reaches `renderToss`, so the log is empty no + matter what — it now measures `state.tosses.length` directly. +- Verified on the running page as well: with a throwaway runtime up, six clicks on 掷钱 left `#toss-btn` + carrying `disabled=""` and `#toss-finish` enabled, and 重来 put both back exactly at the start state + (one clickable, one disabled). +- The casting beat used to play only the engine's own 取数 steps — two for the coins, three for + numbers — so the speech ran out before the hexagram was even formed. It now follows with the four + lines that come after, in order of importance: the upper trigram, lower trigram and resulting + hexagram, the moving lines and the changed hexagram, the palace with its 世 and 应, and the body + and use with their elements. All four methods produce all four, and every word comes from a field + the engine has already computed. When the budget runs short it is the body-and-use line that goes; + the other three may never be squeezed out. That budget had a hole in it once: the closing line's + characters were never counted, so on a wordier cast the real duration pushed past the budget and + the last line was cut off by the page change. The closing line reserves its share now, and the hold + takes its length from the constant `CASTING_HOLD_MS` (the 4s typing budget plus 400ms) instead of + a second number at the call site. The four methods measure 3904-4000ms of typing against a 4400ms + hold. The log is held at 320px, which fits the 11 lines the beat can reach (10 under the line cap + plus the closing one) — each is nowrap at a fixed line height, so a pixel short of that is a + missing pixel. +- The fourteen assertions added for this round were each mutation-tested: six on the top bar + (pinned, blurred ground, rule, stacking, cancelled padding, wrapping the sections), five on the + left column (pinned, offset following the bar, viewport-bounded, height measured back, not a dead + number), four on the beat (all four lines present, the three important ones never squeezed, the + closing line kept, the fixed height fitting 11 lines), and two on the timings (not through the + budget, hold taken from the constant). Each implementation was broken on purpose to confirm the + matching test really went red; all 22 were caught, and the suite reports 212 passing after the + script restored the files. One trap turned up while writing the script: restoring only in + `finally` means mutation N runs against the file mutation 1 already broke, so anchors go missing + and the red counts climb — which reads as "the test never pinned it" when it was contamination. +- The package's own tests (`node --test "tests/**/*.test.mjs"` from this directory) cover the + hexagram table, the 错卦/综卦/互卦 derivations, the ganzhi anchors, the twelve offices, the + 建除 cycle, the nine-day period, the coin rules, the response timing table, each method's change + cadence, the element-to-topic matching, and store round-trips. The classic texts are covered + entry by entry: all 384 爻辞 are checked against the hexagram diagrams, all 384 小象传 against + their 爻题, and all 64 彖传 against the hexagram table. The twelve 辟卦 are checked against + both the month branches and the hexagram table, and the four derivations against their own rules, and the casting timing against the typing budget, and the lookup default plus its explicit full-text +return. The 京房 layer is checked on nine more counts: the palace order against the transmitted +table, 归魂's and 游魂's flipped lines, the eight 纳支歌诀 clauses, branches following the +trigram's polarity, the 六亲 mapping, 世应 pairing without overflow, the reading and diagram +call sites, both lookup levels, and 主客 always straddling the two trigrams. 用神 is checked on +four more counts: the nine topics against their sources, the line-picking order, 婚恋 refusing +to pick for the querent, and 用神不上卦 refusing to invent. 伏神 is checked on five more: both +worked examples reproduced character for character, all 64 hexagrams scanned so every missing 六亲 +resolves to exactly one 伏神 **from its own palace**, the four 飞伏 names, the emergence conditions +naming what they cannot check, the right-hand panel reporting the line it hides under, and the +MCP response carrying the same thing as a field. 旬空, 月破 and 入墓 are checked on fourteen more: +the 旬空 歌诀 against the algorithm, both worked examples from the text recovered from their day +pillars, the twelve months of 月破, the five elements' 墓, the seasonal void, false-void rescue +and true-void grounds, the emergence path actually reaching 「终不得出」 on void alone, the +休囚无气 clause, 空 破 墓 暗 日破 冲散 reaching the diagram and the right-hand panel, a 月破 line +counting as 真空 even when it is not 旬空, that sentence no longer telling the reader to wait for a +clash, and the 逢月破 ground not being said twice once the mark already names it. 六神 is checked on four +more: the 歌诀 verbatim, all thirty-two cells of the six-row table, both 乾为天 examples, and the rule +that a god must not move the verdict — the same hexagram cast across twenty-eight days has to hold its +verdict while the god under its first line changes hands. 化爻 is checked on nine more: the two +quoted directions of 回头生 and 回头克, all twenty ordered element pairs landing in five classes of +five, 《卜筮正宗》's five 回头克 cases checked against the 生克 table, the other three relations +carrying no verdict, the 歌诀's sixteen pairs stored verbatim and each checked same-element and +reverse, the quote that confines a changed line to its own moving line, the changed line kept still +so it cannot be rescued as 发动, the reading and the structured field agreeing, and MCP carrying +it. The removed 绝 mark is checked too — a test asserts no 纳支 can land on its own 绝 branch, so +nobody adds the label back thinking it was forgotten. The 元神 / 忌神 / 仇神 circle is checked on +eight more: the book's 金 example reproduced exactly, all five elements following 「余仿此」 with the +three positions never colliding, the 仇神 proved to 反生忌神 and not 生用神, the circle withheld when +the 用神 went to the 伏神 or when two candidates stand, each of the three reported with its line, its +动/暗动/静/动而逢日冲 and its 旺衰, 「勿以仇神即仇人也」 kept, 回头克 read four ways — 凶 on the 用神, 不作凶论 on a +忌神 or 仇神, unjudged on a 元神, and not borrowed when there is no circle — and MCP carrying it. +暗动 · 日破 · 冲散 is checked on fifteen more: the chapter's 坤之师 example reproduced line by line and +pinned as 日破 so the definition and the example cannot drift apart unnoticed, a clashed line dropping out +of both still-line lists once it is moving, all sixty-four hexagrams over twelve months and twelve days +confirming no line ever lands in two of the three, 暗动 and 日破 never sharing a reading, the 用神 section +reporting 暗动 as its own state rather than folding it into 动 or 静, 元神 暗动 coming out 喜 and 忌神 暗动 +coming out 忌, 仇神 and off-circle 暗动 left unjudged, no verdict attached when the 用神 is not +settled, the 「用神休囚」 premise called out when the 用神 is 旺相, the chapter's own 风水涣 to 坎为水 +example reproduced (丑月丁酉, top line 卯木 moving and clashed by 酉), 旺相 and 元神 and 用神 cases each +placing the 冲散 exactly, the sentence refused the right to pronounce 凶, 冲散 proven independent of the +month's build the way the test could actually fail, the 用神 section giving 「动而逢日冲」 its own tier +instead of folding it into 动, and the diagram and MCP both carrying it. The two MCP header tests sweep all +sixty-four hexagrams rather than pinning one cast, because the tool runs on the real clock and a fixed cast +would quietly stop exercising the branch it names. 旺衰 is checked against all eight non-seasonal months +of 《四时旺相章》 when the module loads, so transposing 囚 and 死 throws instead of silently reporting. +六冲 · 六合 is checked on nine more: the ten and the eight pinned name for name, the pairing offsets +pinned to 初四、二五、三六 so the naive inner/outer pairing cannot creep back, the pairing table +itself read from 纳甲, the 「one pair proves three」 fact held across all sixty-four, no hexagram +counted as both, each of the four reportable ways placed on a real reading, 爻与爻冲 kept below +六冲卦 and never opening a section on its own, the 用神-conditional half applied at 旺 and at 囚 and +declined when the 用神 is not settled, the 近病/久病 rule quoted without a side picked, the +官讼 clause joined only when the matter really is 官讼是非, and the diagram plus MCP carrying it. +The margin arcs are checked too: that they are drawn only after the diagram is on the page (measuring +row heights while the node is still detached yields an empty picture), that they take their width from +the gutter rather than out of the hexagram, that the arc's reach still fits inside that gutter, that +they are muted and never red, that they do not intercept clicks, and that each one names its two +branches. The 八宫名单 is checked too, on both sides. Ten checks on the data: the eight slots sit in generation +order and their names match the received ordering palace by palace (each slot's name and key must be +the same hexagram); the 世 walks up one line at a time from 一世 to 五世 while the flipped lines grow +by one each step; the 游魂 slot does not flip the fourth line; the 归魂 slot flips only the fifth; +every slot's 世 and 应 sit three apart; all sixty-four hexagrams find their own slot and agree on 世 and +应 with the hexagram's own reading; exactly sixteen hexagrams are 游魂 or 归魂 (that slot carries two +marks when it is your own hexagram, which is what the client's "red wins over bold" rule is there +for); and the whole roster including each slot's flipped lines is frozen, since the eight hexagrams +of one palace read the same array. The roster is also checked not to be a second copied table — it +must be the one that came out of the same derivation as the palace and generation themselves. Eight +checks on the page: the row is really inserted rather than a function that is never called, it sits +ahead of the four derivations, the heading names the palace and its element, the caption explains +where 游 and 归 come from, the small-hexagram styles are not scoped back under .derive (doing that +would drop the red flipped lines from the eight slots), the derivation diagram's own arrows and row +spacing stay scoped under .derive, the red self-slot rule is written after the bold 游归 rule at the +same specificity, and the eight slots mark flipped lines but not the 世 (the hexagram to the left +already boxes it in red). +爻之合 is checked too. Nine checks: the 六合 table pairs the twelve branches without repeat or gap +and the pairing is bidirectional; a day branch equal to the month branch counts once rather than +twice; 合起 takes only still lines and 合绊 only moving ones, the two never overlapping; 合好 +requires both lines moving (a still-moving pair does not count, and exactly twenty of the sixty-four +have a harmonizing pair outside 初四二五三六 — 雷火丰's first 卯 against its top 戌 gives the +one-moving/one-still contrast); 化扶 requires a moving line whose transformed line combines back; the +combined-line positions are exactly the union of the four paths; the reading names all four without +deciding 吉凶 (both closing lines of the chapter are quoted verbatim); the hexagram gets a muted 「合」 +mark, and among the mark rules that take 朱砂 only `po` and `tomb` may appear; and MCP carries a +`combine` field plus a 【逢合】 header line. 爻之刑 is checked too. Six checks: the base text's six and the fate-reading set of eight are not merged (卯刑午 stands, not 卯刑子, and 未辰相刑 is not split into 未刑丑 and 戌刑未, since merging both sets would make 「how many lines are punished」 meaningless); punishment has a direction (卯刑午 holds while 午刑卯 does not); self-punishing 辰 is always 0 across the sixty-four, fixed by the 纳甲 (辰 only sits in the inner lines and a hexagram has one lower trigram), while 午, 酉 and 亥 all come out and a self-punishing pair is reported only once; the book's case reproduces step by step (寅月申日, 风火家人 changing to 离卦, the month branch 寅 punishing the fifth line's 巳 and the day branch 申 punished by that same 巳, both on one line); the reading does not decide 吉凶 from punishment and checks 「用神休囚」 and 「又兼他爻犯之」 one by one; the hexagram gets a muted 「刑」 mark and MCP carries a `punish` field plus a 【犯刑】 header line. A load-time check sweeps all sixty-four hexagrams across +twelve day branches, twelve month branches and all sixty-four motion patterns, verifying what makes +each of the four paths valid rather than merely whether it fired. +The client tests read the source, since there is no DOM in the test runner. +212 passing. +- 算法口径: the day pillar is computed from the Julian day number and matches the traditional + almanac (2000-01-01 is 戊午). The month branch follows the nearest of the twelve 节, whose dates + are the usual yearly approximations and can be off by a day. The year branch turns at 立春, + approximated as February 4. There is no lunar calendar in this package, so it does not convert + lunar dates and does not claim to. +- 时间起卦 uses 公历月 and 日, which is the common modern simplification; the classical form uses + the lunar ones. The app states this in the 起卦依据 panel rather than hiding it. +- The cadence claims are test-backed: a test casts inside one 时辰 and across the boundary, and + checks the predicted next-时辰 hexagram against a real cast in that 时辰. +- This round read the verdict block on the **real page** field by field, one cast per question across + seven of them. Five named their matter — 事业, 财运, 疾病, 房产 and 出行 came back as 「这份前程」, + 「这笔进项」, 「这桩病症」, 「这处房产或这纸契」 and 「这趟行程或这件失物」. Two left the sentence off + by design: 「明天的会议会顺利吗」 is simply outside the nine classes, and 「他对我还有没有真心」 is a + real gap — the 感情 keyword list does not catch that phrasing, so the line disappears rather than + being filled with something that happens to fit. Adding a word for it means weighing phrasings like + 「公司对我是不是真心的」, so it is recorded here rather than quietly widening the table. The seven + runs covered 大凶, 大吉, 吉 and 平; the caution appears on every non-平 reading and is absent on 平, + as coded. In the DOM the block really does precede both columns and the right column no longer + carries a second copy of the summary. The toss progress is a `role="group"` named 「摇卦进度」 holding a + `role="status"` 「已摇 0 / 6 爻」, with the bar width and the counter pinned across 0–6. +- One environment note worth keeping: **the in-app Browser on this machine does not deliver clicks.** + `click` returns `success` / `dispatched: true` and the page does not react — the section tabs do not + switch, the input keeps its placeholder — and every `ref`-based click reports `STALE_ELEMENT_REF`. + The page's own JavaScript is alive (setting `document.title` takes effect at once), and timers in a + background tab really are throttled (`setInterval` goes tens of seconds without a callback). The way + through is to let the page drive itself: a second same-origin proxy in front of the same server + injects a driver script into the real page, clicks are fired by that script, and waiting uses the + `load` event of a deliberately delayed image as the timer, since events are not throttled. Results + are written into a `<pre>` on the page and the Browser only reads. That path did produce a result + page from a real click — the check that stayed blocked in the previous round. Casting a second + time in the same tab sent `POST /cast` and never saw it return; hitting the real server and the + proxy directly both cast repeatedly in milliseconds, so the stall is the client animation's + throttled timers, not the server or the proxy. +- Every new assertion this round went through mutation testing: the block's position and the order of + its seven parts, 宜/忌 and the caution each rendered exactly once, no duplicate summary in the right + column, no arrow in the fact table, the short word taken only from after the colon; one distinct + 取象 sentence for each of the nine classes with none repeated, none when the class is unrecognised, + strength following the total score; and the progress bar's width and counter across 0–6. Each was + broken in turn to confirm the matching test actually goes red — all 17 pinned, and 214 pass with 0 + fail after the script restores the baseline. +- This round moved two decisions out of static analysis and into the model. `divination_cast` gained a + `topic` enum that the Agent fills in after reading the user's own words; the page still uses the + keyword table, because on a page there is nobody to read the question for you. Both paths were + checked: for 「他对我还有没有真心」 the keyword table catches nothing (`detectTopic` returns null and + the topic sentence is left off entirely), while `topic: love` puts the class, the topic sentence, the + 用神 and the 应期 all where they belong; and for 「这工作该不该跳」, which the keywords *would* have + filed under 事业功名, an explicit `wealth` wins. A value outside the nine is rejected outright + (`INVALID_ARGUMENTS`, with "omit it if you are unsure") rather than ignored — silently ignoring it + leaves the Agent believing it landed. Across five topics the 吉凶 was checked item by item: `label`, + `score` and the 体用 relation do not move. +- The plain block is that same apparatus with the terminology taken out, not a second opinion. 体卦 + becomes 「你」, 用卦 becomes 「那件事」, and 旺相休囚死 becomes how much force you have right now — + the three most abstract things in the method. None of 「体卦 / 用卦 / 旺衰 / 类神 / 月令」 may appear + anywhere in it, checked one by one in the tests. Writing that assertion is what caught 「月令」 and + then 「旺衰」 slipping into my own copy; it took two passes to get clean. Where the 生克 layer and the + total disagree (体克用 is 小吉 on 生克 alone, but a 体卦 on death ground drags the total to 平), the + difference is spelled out rather than left for the reader to suspect a broken calculation. The sample + response in the README was generated by running the code, not written by hand. +- Every new assertion this round went through mutation testing — all 26 pinned, and 217 pass with 0 + fail after the script restores the baseline. The first run left eight unpinned, and six of those were + the tests being too soft rather than the mutations being clever: the 旺衰 check only asked whether a + term had *leaked* and never whether it had been *translated* (pasting the raw 旺 back in stayed + green); the blank-line check pointed at the wrong function; the ordering check passed with the other + line deleted, because `indexOf` on a missing string returns -1 and -1 is still less than -1; and two + places read `.topic.key` directly, which throws a TypeError when the topic is null — and a TypeError + carries no message, so a keyword-based "is it pinned" check can never match it. Optional chaining + fixed those two. The other two were faults in the mutations themselves: one left the file a syntax + error, so the run reported a SyntaxError instead of any assertion, and one edited the *main* block's + filter rather than `plainBlock`, which has nothing to do with what it claimed to break. +- **The coins path was broken end to end over MCP, and the user is the one who reported it.** + `divination_cast` with `method: coins` always failed with 「六次掷钱结果必须是 6 到 9 之间的整数」: + `tossCoins()` performs one toss and returns a `{ sum, coins }` object, while `castByCoins()` wants + the six results as an array, so the object's `.length` is `undefined` and not one call got through. + The page route (`server.mjs`) collects six tosses separately and composes them, so only the MCP side + was affected — the user found it by casting through the engine directly, bypassing MCP. The fix is + one line. The same read turned up that `castByCoins` does not reject `NaN` or `undefined` (a + non-number compares false against anything), so a hand-made request quietly composed an all-zero + 坤卦 that looked like a hexagram while nothing had been tossed; `Number.isInteger` closes that too. +- Why earlier rounds missed it: the engine-level `castByCoins` has dozens of tests that all feed it an + array, and the MCP end-to-end coverage only ever exercised `numbers` — `time`, `daily` and `coins` + were never called at all. No method had end-to-end coverage. All four are exercised now: the hexagram + name and order land in 1–64, the verdict is one of the five, the plain block is at the end, and **all + four must pass the Agent's topic through** (dropping it on one still yields a complete reading, which + is invisible from outside); coins additionally checks that the 起卦依据 line really lists six tosses, + and that twelve consecutive tosses do not all produce the same hexagram. +- 「Time casting uses the present 时辰」 now has evidence behind it too: the 「月 · 日」 line in 起卦依据 + is checked against today's date. **Comparing the 月令旺衰 instead does not work** — that comes from + `buildReading`'s `now`, so passing 1970 to `castByTime` still yields the current month's element and + both sides agree. The midnight boundary is covered by accepting either the date before or after the call. +- The SKILL gained a step 「起卦前先把这件事问清楚」: casting the moment the user asks produces an + interpretation that will not attach to their actual situation — abstract and thin, which is exactly the + failure being avoided. That section, the nine topic keys and 「relay the plain block at the end」 are all + asserted: it is the Agent's entry point, the repository check only verifies the file exists, and one + tidy-up edit can remove any of it silently. +- All 14 mutations this round are pinned, and 219 tests pass with 0 fail after the script restores the + baseline. The first run left five unpinned: two were real gaps (no method was checked for passing the + topic through, and 「present time」 had nothing to check against), two were wrong expect keywords on my + side, and one was **an equivalent mutation** — `Array.isArray` is redundant next to the length check, so + removing it changes no behaviour at all and the knife had to be replaced. + +## Data & access + +- Files read: only `miniapp/client/index.html` and the Node payload under this package's own + directory. No Host file, user document, or any path outside the package is read. +- Files written: `readings.json` inside `context.dataDir`, the private directory the Host creates + for this Mini App. It holds saved castings and their notes, newest first, capped at + 500 entries. Writes go to a temporary file in the same directory and are renamed into place, so + an interrupted write cannot leave a half-written file. The rename is retried with backoff when + the target is momentarily held open by another process, which is what Windows does when a virus + scanner or the search indexer has the file. A `readings.json` that cannot be parsed is renamed + aside to `readings.json.corrupt-<timestamp>` rather than deleted, so a hand-written note inside + it can still be recovered, and the history then reads as empty instead of failing every request. + Nothing is written anywhere else. +- Network: **no outbound connections.** The Node process opens no outbound connections, calls no + model API, and the page loads no remote assets, fonts, or scripts. The MCP endpoint listens only + on the Host-assigned loopback address `context.listen` and accepts POST only. +- Processes: none spawned. Coin tosses use `node:crypto.randomInt` inside the Node process. +- Secrets: none are read or held. There are no credentials and no Host connector access. +- Requests are accepted only when their `Host` header, and their `Origin` header when one is sent, + name a loopback host. Two headers, two holes: a site that points its own domain at `127.0.0.1` + (DNS rebinding) is stopped by `Host`, which then carries the attacker's name; a cross-origin + request aimed straight at the port is stopped by `Origin`, since `Host` is genuine there. A + missing header is not treated as forged, because the Host's MCP client is a Node program and + sends no `Origin`. A rejected request gets `403` and an echo of nothing. +- Log messages carry the error code and never the error text. Node's file-system errors embed the + full absolute path, operating-system user name included, in `error.message`; `dataDir` is opaque + by contract and that path does not leave this process. Logs get pasted into issues and uploaded. + +## Files + +```text +.minimax-plugin/plugin.json Plugin manifest +package.json Mini App declaration +servers.mcp.json MCP endpoint declaration +skills/divination/SKILL.md Casting and interpretation rules for the Agent +miniapp/miniapp.json Payload roots, Node entry, page route, MCP endpoint +miniapp/client/index.html The page served at /divination +miniapp/node/server.mjs Node entry: routes, MCP mount, start(context) → { dispose } +miniapp/node/hexagrams.mjs Trigrams and the sixty-four hexagrams +miniapp/node/yao.mjs The 384 爻辞, cross-checked against the hexagram diagrams +miniapp/node/xiang-chuan.mjs The 384 小象传, cross-checked between two editions +miniapp/node/tuan.mjs The 64 彖传, cross-checked between two editions +miniapp/node/guaqi.mjs The twelve 辟卦, one per month branch +miniapp/node/jingfang.mjs 京房's eight palaces, 纳支歌诀, 六亲, 世应 +miniapp/node/xiang.mjs Line positions and response timing +miniapp/node/topics.mjs Element-to-topic: question → topic → 类神 +miniapp/node/almanac.mjs Ganzhi, the twelve offices, solar terms, zodiac +miniapp/node/divination.mjs Plum Blossom casting and interpretation +miniapp/node/store.mjs Reading log persistence in dataDir +miniapp/node/mcp/divination-http.mjs MCP protocol layer and its three tools +miniapp/node/miniapp-api.ts Type declarations for the runtime context +tests/divination.test.mjs Unit tests, outside the runtime payload +icon.png Plugin icon +``` + +## A note on use + +**The text in this app is generated by AI. It is for entertainment only and has no predictive +function.** A disclaimer saying exactly that sits at the bottom of every page. + +The app implements a traditional method of divination faithfully; it is a cultural and +philosophical tool, not a forecasting service. Treat a reading as a prompt to think clearly about +a question you already have, not as a prediction to act on. Nothing here should inform medical, +legal, or financial decisions. + +## License + +[MIT](./LICENSE) diff --git a/plugins/weekbin/chinese-divination/README.zh-CN.md b/plugins/weekbin/chinese-divination/README.zh-CN.md new file mode 100644 index 0000000..8817abe --- /dev/null +++ b/plugins/weekbin/chinese-divination/README.zh-CN.md @@ -0,0 +1,1298 @@ +# 灵签易占 (`chinese-divination`) + +[English](README.md) | 简体中文 + +中国传统文化占卜 Mini App。以梅花易数起卦,按体用生克断吉凶,内置六十四卦全文与干支历法。 +全部计算在本机完成:不联网,不读取包目录以外的任何文件。 + +界面为中文。想直接上手,看下面的**使用指南**;想看推演口径与能力披露,往下翻。 + +## 使用指南 + +### 三十秒上手 + +安装后对 MiniMax Code 说「打开灵签易占」。页面开在四个分区:**起卦 / 卦库 / 历法 / 卦历**。 + +想在对话里直接起卦,不用开页面,说一句「帮我起一卦」就行,见下面的[在对话里起卦](#在对话里起卦)。 + +### 怎么问才对 + +**第一行是重点。** 上面那个「所问何事」输入框决定了你这次问的是哪一类事,它会改变断语的侧重: + +| 你写 | 认出的事类 | 应期会指向 | +| --- | --- | --- | +| 下个月这份 offer 该不该接 | 事业功名 | 巳午(相辰戌丑未) | +| 这笔投资还能不能赚 | 财运 | 申酉(相亥子丑) | +| 他会不会主动来找我 | 感情 | 亥子(相寅卯) | +| 明年春天结婚日子好不好 | 婚恋 | 寅卯(相巳午) | +| 父亲的手术要不要等 | 疾病 | 辰戌丑未(相申酉) | +| 这套房该不该买 | 房产车契 | 辰戌丑未(相申酉) | +| 下周的考试能过吗 | 学业文书 | 寅卯(相巳午) | + +写具体比写「我的运势好不好」有用得多——**前者能认出事类、算出应期,后者只能退回按用卦推**。 +如果你写的事实在认不出(比如「今天天气怎么样」),应用不会瞎套一个五行,会在断语里明说「未落到已知事类, +应期按用卦推」。**「没写问题」与「写了但这张表没接住」是两回事,断语分开说**:后者会把九类事类 +列出来告诉你是哪一层缺了,不会说成你没写。 + +**问事不影响吉凶。** 同一个卦,问财和问婚,「凶」不会翻面——吉凶由卦自己的体用生克与月令旺衰决定。 +变的是应期落在哪几个月日、哪条断语更值得看,以及结果页顶部那句「问的是事业功名,卦里说的就是 +这份前程」——换个问法换的是这句话的取象,不是吉凶。这一点是刻意的:占卜可以帮你把问题想清楚, +但不该由你写什么问题来买到一个好答案。 + +这句要认得出事类才说得上。上面那九类之外的事,应用宁可整句不摆,也不拿一句听着像的话来凑。 + +### 四种起法怎么选 + +| 你的处境 | 用哪个 | 会变卦的频率 | +| --- | --- | --- | +| 心里有个「现在到底怎么样」的疑问 | **时间起卦** | 每时辰一换(两小时) | +| 只想知道今天整体如何 | **每日一卦** | 一天一换 | +| 已经想好要问什么,想自己定个数 | **数字起卦** | 换一组数字就变 | +| 要一个跟当下完全无关的、随机的卦 | **铜钱摇卦** | 每掷一次都不同 | + +前两种是**按时间取数**的,所以同一个时辰/同一天内反复起卦,必然是同一个卦——这不是程序在复读。 +应用在结果卡上会写明自己的节律:当前取数依据、下一次何时变,甚至会直接告诉你「到丑时再起,将得 +风山渐」。你可以看着它转。想每次都不一样,用铜钱摇卦或数字起卦。 + +### 数字起卦怎么用 + +1. 点「数字起卦」,输入框会展开。 +2. 静心默念你问的事,**心里想出两个数**(不必真的想,顺手取也行)。 +3. 填进去,点「起卦」。第一个数取上卦,第二个取下卦,两数之和取动爻。 +4. 起完输入框会自动清空,免得你连点两次看到同一个结果而以为它写死了。 + +「随手取数」按钮会替你随机取两个 1–99 的数,适合你就是想快速来一卦的时候。 + +### 铜钱摇卦怎么用 + +点「铜钱摇卦」展开摇卦区,点「掷钱」六次,自初爻向上。按钮上方有一条进度条与「已摇 n / 6 爻」的 +计数,每掷一爻跟着走一格,剩几爻不用自己数。界面会逐爻显示阴阳与老阴老阳,六爻掷完 +后点「成卦解卦」。想重来点「重来」。这是四种起法里唯一每次都不同的。 + +### 结果页怎么看 + +| 位置 | 内容 | +| --- | --- | +| 卦题 | 写了所问就显示所问,没写就按起法命名(今日之卦 / 此刻之卦 / 心中之卦 / 手掷之卦) | +| 结论块 | 整块占满版心,挂在卦题正下方、**两栏之上**——大白话那句话是页面上第一眼看到的东西,不是埋在信息洪流中段的一段。依次是:朱砂大字吉凶加一个可执行短词(大吉 / 可进)、讲缘由的体用生克那句话、讲「这一卦说的就是你问的那件事」的那句、灰字「怎么算的」、宜 / 忌、以及提醒。下面的推导是「凭什么」,读不读都在那儿 | +| 右上角 | 同一个吉凶与体用关系,缩成一枚徽标。它跟结论块开头那个数是同一个数,页面上不会出现两个互相打架的吉凶 | +| 左侧 | 本卦与变卦的六爻图、卦辞、彖传、象辞;爻画左侧标六神,右侧标干支与六亲,世应用朱砂框标出,伏神用小字虚框挂在所属爻下,空破墓暗日破冲散合各作小标,用神那一圈的元神、忌神、仇神则各标在所属爻上;动爻标红打叉,本卦下方引动爻的爻辞与象传;六冲六合那十八卦,边上另有三支淡弧把初四、二五、三六接起来 | +| 右侧 | 体卦/用卦的五行、方位、主客、六亲世应、卦体冲合(本卦是六冲还是六合、变卦是什么)、用神(说清在哪一爻,或伏神伏在哪一爻)、日辰冲爻的暗动与日破与冲散、爻之合的合起合绊合好化扶、爻之刑的犯刑(谁刑了谁)、月令旺衰、旬空与月破、八宫名单(同宫八卦按世次排成一列,标出各卦变过的爻)、四卦推导图、卦气消长环 | +| 断语 | 十七到二十二段,第二段是「动爻爻辞」,含卦气、六亲世应与用神三段;写了问题会多一段「所问之事」。「暗动 · 日破 · 冲散」插在「用神」之后,只在真有爻被日辰冲到时才出,约四成六的卦有;「逢合 · 合起合绊合好化扶」排在「六冲」之后,只在真有爻逢合时才出,约八成一的卦有,四名各占约三成到五成,合好一成半;「犯刑」排在「逢合」之后,只在真有爻或日月刑到另一爻时才出,约九成的卦有(六十四卦里有 37 卦的纳甲本身就能碰出爻与爻相刑,余下的靠日月那一路补上);「六冲」只在卦体本身是六冲或六合、变卦是六冲、或有动爻变冲时才出,约四成八的卦有;「反伏与卦变」排在「犯刑」之后,只在真有反伏或卦变时才出,手掷铜钱约 6.4%,时间、每日与数字三种起法恒动一爻,这一段在它们那儿永远不出 | +| 起卦依据 | 每个数怎么来的,全部摊开给你看,不藏 | +| 存入卦历 | 可以补一句批注,存进本机 | + +断语里的**应期**是最实用的一段:它告诉你这件事大概在哪个地支对应的月份或日子会见端倪。 + +### 卦历 + +存下来的卦按时间倒序排在「卦历」分区,显示卦题、卦名、吉凶、时间和批注,可以单条删除。最多存 500 条。 +这些只存在你自己的机器上,不上传任何地方。 + +### 在对话里起卦(MCP) + +除了打开页面,你还能**完全不打开页面**,直接在对话里让 Agent 帮你起卦——它通过本应用注册的 +MCP 端点调用本机上的推演逻辑。两种方式用的是同一套算法,结果一致。 + +#### 怎么确认能用 + +1. 本包要**装在 MiniMax Code 里**(复制到 `~/.minimax/plugins/chinese-divination/`,见下面的「安装」)。 +2. **重启 MiniMax Code**,让 Host 读一遍插件目录、注册 MCP 服务器。 +3. 然后直接说话就行,不用打开页面。 + +#### 怎么说,Agent 会怎么做 + +| 你说 | Agent 调用 | 关键参数 | +| --- | --- | --- | +| 帮我起一卦 / 看看现在怎么样 | `divination_cast` | `method: time`(默认,按当下时辰) | +| 今天什么日子 / 今天黄历 | `divination_almanac` | 无参数 | +| 谦卦什么意思 / 查一下水雷屯 | `divination_hexagram_lookup` | `query: 谦` | +| 掷铜钱 / 随机来一卦 | `divination_cast` | `method: coins`,本工具替你掷六次 | +| 今天这一卦 | `divination_cast` | `method: daily` | +| 我心里想着 3 和 8,起一卦 | `divination_cast` | `method: numbers`,`upper: 3` `lower: 8` | + +你**不必**指定起法——Agent 会按你的话挑。想指定就直接说「用时间起卦」「掷铜钱」「按今天的日子起」。 + +**最重要的一点:把你问的事说出来。** Agent 会把你的原话填进 `question`,并自己判断它属于哪一类, +用 `topic` 报上来。只说「起一卦」而不说问什么,也能起,但认不出事类,应期只能退回按用卦推。 + +这个分工是刻意的。页面上没有能替你读问题的人,只能拿关键词去撞——「他对我还有没有真心」这类问法 +表上一个词都没有。对话里 Agent 刚读完你问的什么,它不需要哪个词命中就能把这件事归到感情。 +关键词那条路留着当退路:`topic` 不填时才轮到它。 + +#### 三个工具的参数与返回 + +**`divination_cast` — 起卦并解读** + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `question` | string,≤120 字 | 所问何事,原话即可 | +| `topic` | `wealth`/`career`/`love`/`marriage`/`health`/`study`/`property`/`dispute`/`journey` | 所问归九类中的哪一类。**由 Agent 判断**,不是关键词撞出来的;拿不准就省略,省略后由关键词表接手。决定类神五行、用神与应期,**不改变吉凶**。给了不在九类里的值会当场报错而不是悄悄忽略,让 Agent 知道自己没挑上 | +| `method` | `time`/`daily`/`numbers`/`coins` | 不填按 `time` | +| `upper` / `lower` | 整数 1–1e9 | 仅 `numbers` 用:取上卦 / 下卦 | + +返回分两块:`content[0].text` 是给模型读的成文断语,`structuredContent` 是给程序用的结构化字段 +(`method` / `question` / `topic` / `hexagram` / `changed` / `verdict` / `useGod` / `transforms` / +`dayClash` / `clash` / `fanfu` / `timing` / `disclaimer`)。`fanfu` 给出这一卦落在哪一档(`kind` 为「内卦」「外卦」「内外」「卦变」)、内卦与外卦各自反伏与否,以及反伏那一侧从哪一组纳支换到哪一组(`innerSwap` / `outerSwap`);`useGod` 给出取了哪一亲;用神不上卦时,`hidden` 里 +带着伏神——`position`、`hushen`、`feishen`、`flying` 与出伏结论 `emerges`;用神定下来时,`circle` +带着元神忌神仇神的爻位。`dayClash` 给出暗动、日破与冲散三路的爻位,成文抬头另有一行【日冲】, +真有可报时才出。`clash` 给出本卦是不是六冲卦或六合卦、变卦是不是、是六合变六冲还是六冲变六冲、 +哪几个动爻变出来冲本位、初四二五三六三对各是什么判法,以及零散爻与爻冲的爻位对;抬头另有一行 +【卦体】,真有可报时才出——出段率约四成八,所以那一行压得很短。抬头另有一行【反伏与卦变】,出段率约 6.4%,两档分开报、合并不了。两行都压得很短,模型不必再从成文断语里刨这几句话。 +实际返回长这样: + +```text +【起法】数字起卦 +【所问】下个月要不要接这个offer +所问事类:事业功名(由 Agent 指定),类神五行 火。 +【卦名】火地晋(第 35 卦,⚊⚋⚊⚋⚋⚋),上卦 离火、下卦 坤土 +【变卦】天地否(上卦 乾、下卦 坤);动爻去向 五爻化泄 +【爻象】初爻 静爻、二爻 静爻、三爻 静爻、四爻 静爻、五爻 老阴、上爻 静爻 +【体用】体卦 离火,用卦 坤土 +【京房】乾宫游魂卦,属金;世爻四爻持兄弟,应爻初爻为父母 +【卦体】本卦非六冲非六合 +【犯刑】月建与4爻酉自刑 +【月令旺衰】当令 金,体 囚、用 休 +【吉凶】大凶 —— 大凶:宜止 +【断语】 +【卦象总断】本卦火地晋,晋,康侯用锡马蕃庶,昼日三接。… +【所问之事】所问归「事业功名」,类神取火。…类神火生体卦离火… +(共十六段) +【宜】守成,不宜扩张、先处理内务再对外、避开正面对抗 +【忌】正面强争、额外投入与加码、在对方主场行事 +【起卦依据】第一数 3 除 8 余 3 → 离卦;第二数 8 除 8 余 8 → 坤卦;动爻 11 除 6 余 5 → 五爻 +【大白话】 +你问的是「下个月要不要接这个offer」。 +我把它归到「事业功名」这一类——这一类以火为事。 +这一卦给的是「大凶」,宜止。 +体卦离火是你,用卦坤土是那件事——这件事要你往外掏;你被局面困住,处境受制。照两人之间的关系本该是凶,你这个月的状态把它拉到了「大凶」。 +问的是事业功名,卦里说的就是这份前程——这件事要你往外掏,这一卦落在它上面不顺。 +时间上:巳午月或巳午日见端倪,到辰戌丑未前后渐明。 +该做的是:守成,不宜扩张、先处理内务再对外、避开正面对抗。别做的是:正面强争、额外投入与加码、在对方主场行事。 +体用相制:局面不在你手上,宜守宜退,不宜正面强求。 +【提示】本结果由传统占卜法按规则推演……(免责声明全文) +``` + +上面一半是家伙,下面一半是答案。 + +**最后那一块才是给你读的。** 上面那些是给 Agent 复述卦象用的——卦名、纳支、六亲、用神,整副家伙。 +末尾的【大白话】是同一卦把术语撤掉之后的样子: + +- **体卦是你,用卦是那件事。** 这是页面永远替你做不了、而 Agent 从来不必学的那一次翻译: + 叫「体」的那个经卦不是你的身体,是含动爻的那一卦,而那一卦就是求测的人。 +- **旺相休囚死是你此刻有多少劲**,不是五个五行在月令里排第几。旺是「你此刻最有力气」, + 死是「你气力最弱,此时强推反而吃亏」。 +- **生克关系变成一句谁对谁做了什么的话**:体生用读作「这件事要你往外掏」, + 用克体读作「外头的力压着这件事」。 +- **两层对不上时它把差价讲开**。体克用单看生克是小吉,可体卦逢死地,总分被拉到平; + 白话把这一层讲明白,而不是留给你自己去怀疑算错了。 +- 取象句、时间、宜忌与提醒也都在同一套说法里。 + +里面没有一句是新判断——每一句都来自上面已经定好的字段,卦象没定的东西它也不表态。 +`initialize` 的提示语里交代了 Agent 照着这一段讲,而不是拿术语把卦象复述一遍。 + +**`divination_hexagram_lookup` — 查六十四卦** + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `query` | string,≤40 字 | 卦名、上下卦名或关键字,如「乾」「雷」「风」。省略则返回全表 | +| `limit` | 整数 1–64,默认 8 | 返回条数上限 | +| `detail` | `brief`/`full`,默认 `brief` | `brief` 省去彖传原文(约省 45% 篇幅),`full` 全给 | + +**这个工具不会为你另起一卦。** 你问「谦卦什么意思」,它给你谦卦的辞义,不会顺手给你算一卦。 + +默认只给卦辞与象辞——查卦多半是问「什么意思」,彖传 61 字左右是「理」的层面, +Agent 多数时候用不上,所以默认省掉,末尾会有一句提示。要原文就传 `detail: full`。 + +```text +匹配「谦」的卦共 1 个,如下: + +【地山谦】第 15 卦,⚋⚋⚋⚊⚋⚋,上坤下艮 +卦辞:亨,君子有终。 +象辞:地中有山,谦;君子以裒多益寡,称物平施。 +京房:兑宫五世卦(属金),世五爻持子孙,应二爻为官鬼 +互卦 雷水解,错卦 天泽履,综卦 雷地豫 + +(以上省去了彖传原文;需要时传 detail="full" 补上。) +``` + +**`divination_almanac` — 查今日历法** + +无参数。返回年月日时四柱干支、当前节气、月建五行、当前时辰(带时柱与吉凶)、黄黑道吉时、 +建除十二神、数九。 + +```text +【日期】2026-09-29 +【干支】丙午年 丁酉月 丙午日 戊子时 +【节气】白露,月建 丁酉(金) +【当前时辰】子时(23:00 - 01:00,司命·黄道吉时) +【黄黑道吉时】子时、寅时、卯时、午时、未时、酉时 +【建除十二神】收 +【数九】未入数九(数九只在三九、九九两段) +``` + +#### 一个完整的往返 + +> **你**:下个月要不要接这个 offer?我有点犹豫。 + +Agent 调 `divination_cast`,`question` 填你的原话、`method` 用 `time`。你的话被认成「事业功名」, +类神取火,应期落在巳午。它用自己的语言把卦讲给你听——**断语文本由模型现场组织, +卦象由本应用按传统规则算出**。 + +> **你**:那如果不接呢? + +它可以再起一卦(同一时辰仍是同一卦,若想不同就说「掷铜钱」),或换个角度用 +`divination_hexagram_lookup` 查相关卦辞,也可以转向 `divination_almanac` 看日子。 + +分工就是这样:**本应用负责算得准,Agent 负责讲得贴合你的处境。** +本应用自己从不调用任何模型、不出站、不持有任何密钥。 + +#### 排障 + +**Agent 好像不知道有起卦这回事。** +最常见是没重启。Host 只在启动时扫一遍插件目录,MCP 服务器也是那时注册的。重启 MiniMax Code 再说。 + +**说「帮我起一卦」,它却没起。** +直接点一句「用梅花易数起一卦」,或说明「用灵签易占这个插件起」。随包的 +`skills/divination/SKILL.md` 已经告诉它何时该起、何时只是查卦辞;遇到判断不了的情况, +你把起法说死即可。 + +**它没带免责说明。** +这是不该发生的——SKILL.md 里写的是每次转述卦象都必须带。你可以要求它补上。 + +**能不能不装就聊起卦?** +不能。页面和 MCP 都需要本包在 `~/.minimax/plugins/` 下被 Host 加载。 + +### 常见问题 + +**同一时辰起两次,结果一模一样?** +是。时间和日期是确定的数,同一个时辰当然得到同一个卦。想每次不同,用铜钱摇卦或数字起卦。 + +**问事真的有用吗,还是只是换个说法?** +问事决定事类与应期,不决定吉凶。同一时辰里问「换不换工作」和问「父亲手术要不要等」, +卦还是那个卦,吉凶还是大吉,但应期分别落在巳午和辰戌丑未,取象的侧重也不同。这是梅花易数本来的做法, +不是 AI 现编的。 + +**准不准?** +准不准取决于你自己。卦不预测未来,它把你已有的疑问摆到另一个角度,让你看清自己的处境与选择。 +医疗、法律、财务等重要决定,请以专业意见为准。 + +**这些文字是谁写的?** +卦象按传统规则推演,是代码算的;把所有断语组织成通顺中文的是 AI。详见下面的「使用提醒」。 + +**我的问题记录会传到哪里?** +哪里都不传。存卦只写你自己机器上的 `context.dataDir`,应用不联网、不读包目录外的任何文件。 + +## 功能 + +**起卦 — 四法择一** + +| 方式 | 原理 | +| --- | --- | +| 每日一卦 | 依今日年月日确定性推算,同一天结果不变 | +| 时间起卦 | 年支序加公历月、日为上卦,再加时支序得下卦与动爻,问当下事最合 | +| 数字起卦 | 静心默念两数,第一数为上卦,第二数为下卦,两数之和为动爻 | +| 铜钱摇卦 | 三枚铜钱掷六次,自初爻向上;六为老阴、七少阳、八少阴、九老阳,六与九为动爻 | + +**解卦 — 体用生克** + +动爻所在经卦为体卦(我),另一经卦为用卦(事)。吉凶只在一处断,只从一个总分来:生克给权重——用生体 ++2,体克用与比和 +1,体生用 −1,用克体 −2;再按体卦五行在当月令的旺相休囚死加一减一。总分 ≥2 大吉、 ++1 吉、0 平、−1 凶、≤−2 大凶。**生克那一层自己的教科书断语**(体克用作小吉、比和作吉)仍单列出来, +但只作「因」摆在总分旁边,不当第二个答案:页面开头给一个数,紧跟着说清是哪两层把它算成这个数。 +原先这两层各说各的,同屏能同时出现「小吉」与「大吉」,看着像算错了。 + +**四法的变卦快慢** + +这一点必须讲明白,否则容易误以为结果写死: + +| 起法 | 多久变一次 | 什么情况会再得同一卦 | +| --- | --- | --- | +| 铜钱摇卦 | 每掷一次 | 不会,铜钱随机 | +| 数字起卦 | 换一组数字时 | 填了相同的两个数 | +| 时间起卦 | 每时辰一换,即两小时 | 同一时辰内再起 | +| 每日一卦 | 一天一换,零点即变 | 同一天再起 | + +这是术数本来的节律,不是程序在复读。因此每条断语都会在卡片上写明自己的节律:当前取数依据、 +下一次何时变;对两种按时取数的,还会直接给出下一次会得到的卦(例如「到巳时再起,将得风火 +家人」),你可以看着它转,不必猜。 + +**所问何事 — 问事定事类与应期** + +写在起法上方的问题会落到一个**事类**,再由事类取**类神五行**。这是同一个卦在一天之内能解出不同 +侧重的关键: + +| 事类 | 类神五行 | +| --- | --- | +| 财运 | 金 | +| 事业功名 | 火 | +| 感情 | 水 | +| 婚恋 | 木 | +| 疾病 | 土 | +| 学业文书 | 木 | +| 房产车契 | 土 | +| 官讼是非 | 金 | +| 出行寻物 | 水 | + +**类神只管应期与取象,不改吉凶。** 卦的吉凶仍由体用生克与月令旺衰单独推定,所以同一个卦问财与 +问婚,「凶」不会翻面,变的只是应期落在哪几个月日、取象偏重哪一边。认不出事类时不硬套五行, +应期退回按用卦推算,并在断语里说明。 + +> 这张五行类事表是本包**自订**的类象口径,不是传世定法。梅花易数本身没有六爻那样的六亲用神, +> 本包把京房的六亲与纳甲另起一层给(见下面的「京房一层」),不拿它改体用这一层的吉凶。 + +**每次十五段起** + +卦象总断、动爻爻辞、体用关系、旺衰应期、卦气(当令主卦)、互卦(过程)、变卦(结果)、 +错卦(旁支)、综卦(反求)、六亲世应、用神、主客、取象、爻位之象、方所。写了问题会多一段 +「所问之事」,讲清落在哪个事类、类神与体卦的生克——也正是有了这句,用神才取得出来。另有三段 +按卦出:日辰真冲到爻时多一段「暗动 · 日破 · 冲散」,卦体是六冲或六合、变卦是六冲或有动爻变冲时 +多一段「六冲」,有动爻就多一段「化爻 · 变出之爻」,真有反伏或卦变时多一段「反伏与卦变」。所以实际是十七到二十二段。 + +**动爻爻辞** — 真正动了的那一爻 + +卦辞说整卦大势,爻辞说的是动在这一爻时的处境,而那一爻正是你的问题落下的位置。三百八十四条 +全部入库,解读时只引动爻那一条,不是六条全列——六条会把卦盘压成一堵字墙。爻题照传统记法 +(初九、六二、上六),九与六必定与该爻阴阳相合;测试拿三百八十四条逐一比对卦象,错一位就报红。 + +**动爻象传** — 这一爻凭什么这么读 + +爻辞是断,象传是据。三百八十四条也整份入库,动爻的那一条以小字排在爻辞下方,并接在同一段 +断语里,不另起一段。对校两版时翻出几处两版互有出入、有两处还与通行本相抵触:大有九四该用 +「尫」(两版都作「彭」,而爻辞不作),困六三则横跨两版——「蒺藜」取自一版、「不祥」取自 +另一版。这些都写进测试钉住,还有需九五的「酒食贞吉」,第二版把它扩成「需于酒食」,与所有其他 +来源都不合。 + +两处最容易被顺手转错的字由测试钉死:损卦的「已事遄往」是已,革卦的「巳日乃革之」是巳; +噬嗑的「噬乾胏」借乾为干,而乾卦的「终日乾乾」说的是刚健,两处不能一并转成「干」。 + +**彖传** — 这一卦为什么长这样 + +卦辞说「是什么」,彖传说「凭什么」。六十四卦各有一条,入库后排在卦辞之后、象辞之前, +以一档浅色与左边线示出区别——它是解卦之辞,不是卦本身的原话。长彖传动辄百十字, +乾卦那条最长,自「大哉乾元」一路说到「万国咸宁」,把元亨利贞四字逐层拆开。 + +对校两版浮出十处实质差异,九处取底本:蒙卦的「初筮告」(另一版作「初噬告」)、 +小畜的「健而巽」(另一版漏这四字)、革卦的「革而信之」与「巳日乃孚」都与爻辞自洽。 +第十处是乾卦本身,两版一作「保和大和」、一作「保合太和」,按通行本取太和。 +这些裁定都写进测试,连同繁简转换的自动推导(用简体来源与繁体底本按位对齐,导出映射表, +只保留一致对子,再人工剔掉三处由来源错字派生出的假对)一并可复现。 + +**起卦那三秒** — 卦是怎么成出来的 + +点下起卦,八卦环转起来,旁边逐字打出取数的推演。原先六爻只在结果里出现,推演全程与卦体 +无关;现在卦体跟着推演一爻一爻长出来——自初爻向上,长到上爻。**动爻要等六爻都长齐了才 +点红**,不能提前:起卦的意义正在于先成卦、再定哪一爻动,提前指出来等于把答案先说了。 + +时长的正确性由静态测试兜住:等待窗口不得短于推演预算,六爻按最小间隔排下来也必须落在窗口 +之内,所以「打字打得完但卦画不完」这种事不会溜过去。系统开了「减少动效」时,六爻一次性 +出现,不做生长动画。 + +**四卦推导** — 互、变、错、综是怎么来的 + +断语只说「互卦为XX」,看不到怎么互出来的。这一块把本卦摆在上面一行,四种取法的结果摆在 +下一行,每一步中间标出怎么取:互卦取二三四为下卦、三四五为上卦;变卦是动爻阴阳反转;错卦 +六爻阴阳全反;综卦六爻上下倒置。变卦那一格里,动的那一爻染成朱砂,一眼看出「就是它变了」。 + +四种取法各有分工——互卦看中间过程,变卦看最终落点,错卦看另一面,综卦看倒影。这四卦不是 +四条并列的结论,是同一个问题的四个方向。取法本身由测试逐条核:互卦确为二三四、三四五,错卦 +确为全反,综卦确为倒置,变卦只动该动的爻;图上画错一条,测试就报红。 + +**京房一层** — 六亲、世应与纳甲 + +这是与梅花并行的一层。梅花以「动爻所在经卦为体」讲**我跟这件事**;京房以「本卦属哪一宫、 +第几世」讲六爻各自的身份——哪一爻是我(世),哪一爻是对方(应),每爻在五行生克下是谁的什么。 +两套各管一段,不互相替代,所以「世应」这两个字归京房,梅花那层改称「主客」:同一段解读里 +出现两个不同的世爻位会看糊涂。 + +八宫与世次出自《京氏易传》,本包**不抄表**:由八纯卦按爻变规则推——初爻变一世,初二变二世, +初三四变三世,初至四变四世,初至五变五世;五世卦第四爻变回为游魂;游魂卦下三爻收回来为 +归魂。推导结果与传世八宫卦序逐一对撞,六十四卦全部吻合。归魂那句最容易记错:收的是游魂的 +下三爻,净效果只剩第五爻被变;写成变第四、五爻的话,八个宫的归魂卦会整列变成别宫的二世卦。 +测试把这条单独钉住。 + +纳支用沿用两千年的纳支歌诀原文(乾金甲子外壬午、坎水戊寅外戊申、艮土丙辰外丙戌、 +震木庚子外庚午、巽木辛丑外辛未、离火己卯外己酉、坤土乙未外癸丑、兑金丁巳外丁亥), +八条逐一核对。**纳支随经卦走,不随卦宫走**:山水蒙属离宫(阴宫),但下艮上坎都是阳卦, +六爻便全纳阳支。六亲以本宫五行为我——生我者父母、我生者子孙、克我者官鬼、我克者妻财、 +同我者兄弟,取地支五行(纳音不在此列)。 + +世爻一世初、二世二、三世三、四世四、五世五、本宫上爻、游魂四、归魂三;应爻与世爻**配对** +隔三位:初应四、二应五、三应六,返过来四应一、五应二、六应三。照字面写「世 + 3」的话, +本宫卦与五世卦会算到第八、第九爻去——测试专门钉这一条。 + +卦体上,纳甲那一列直接标在爻位右侧:干支定五行,六亲定身份,世应各用一枚朱砂框标出。 +断语里对应多一段「六亲世应」,点出宫与世次、世应两爻的身份、世应之间的生克,以及动爻落在 +哪一亲上(财、官、父母……各自管什么,段里一并写明)。变卦另有一套宫与世次,不沿用本卦。 + +**八宫名单** — 一宫八卦那八个格子 + +京房这一层最难记的不是「我在哪个宫」,是**那一宫里八卦的次序**。一世到五世世爻逐爻上移,看着像 +一架梯子;游魂、归魂两个却往回退(世在四爻、三爻),偏偏又都叫「世」。只写一句「离宫一世卦」, +谁也想不到梯子中间还有两级是倒着走的。 + +所以同宫八卦全摆成一列,排在四卦推导前头——先认清自己在哪一宫的哪一级,再看梅花那四种推法。 +每一格是一枚小卦,标出它相对本宫纯卦变过哪几爻(朱砂)。朱砂框住的是你这一卦,框里那格下面 +写的是世次。游魂、归魂那两格的世次加粗,因为它们是梯子上的两个例外,不标出来会看成六个一串。 + +本卦自己就有落在游魂或归魂上的——十六卦,占全部四分之一。那一格同时挂着「就是你」和「这是 +例外级」两个标记,朱砂压过加粗:框和字都朱砂,一看就知道是自己这一卦,同时也知道它是例外级。 + +小卦上**不标世爻**:世爻在左边卦盘上已经朱砂框出来了,这里再标一遍是两份要人核对的账。 +这一列只管次序。 + +这一列不另抄一张表:拿本宫纯卦作底、按各世次的变爻推出来,与宫、世次是同一套推导,所以八格里 +的卦名与次序逐宫跟传世卦序对得上(六十四卦全部吻合)。整份名单连同每格的变爻都是冻结的—— +同一宫的八个卦读出来的是同一份数组,看过一次八宫图改了它,后面这一宫别的卦读到的就是被改过的, +而且从哪看出来的都看不出来。 + +**用神** — 你问的这一件事,落在哪一爻 + +六亲摆在那里只是摆着,落到「你问的这一件事」上才算用上了。六爻断卦第一步就是取用神: +问何事,取何亲为用神。这一条**有传世定法可依**,与上面五行类事那张自订表不是一回事。 + +| 所问 | 用神 | 出处 | +| --- | --- | --- | +| 财运、买卖、失物 | 妻财 | 财爻是求测者可以支配、可以到手的资源 | +| 事业功名、官司 | 官鬼 | 官鬼为职位、上司、官非;我克官鬼则我胜 | +| 学业文书、房产车契 | 父母 | 父母为文书、成绩、证照与契据 | +| 感情、婚恋 | 妻财(男占)、官鬼(女占) | 《增删卜易》「男测婚以财为用,女测婚以官为用」 | +| 疾病 | 官鬼为病症,子孙为医药 | 子孙克官鬼,子孙有力则病退 | +| 出行寻物 | 妻财 | 所求之物以财论;寻人须按关系另取,本包不替你猜关系 | + +两处刻意的克制: + +- **婚恋分男女,本包不替求测者认性别**,所以妻财与官鬼都列出来,由求测者对照自己那一亲 + 取。择了其一就等于替他认了性别。 +- **占病取两亲**(病症与医药),也是各报所在、不硬择其一。择了就等于替人认了病势。 + +择爻次序本包**从简为两条:动爻优先,其次取近世爻者**。传世取法更细——两爻俱动取旺相者、 +俱静取旺相或临世应者,还要看日辰、旬空、墓库——那些本包不具备条件,就不硬凑一条像模像样 +却无法核验的规则。近世取绝对爻位差,不带旺衰,是能核验的那条。 + +用神不上卦(六亲一个也不在卦里)时,传统从本宫首卦取**伏神**。这一层有明文出处,本包照做。 +《增删卜易·飞伏神章第二十八》: + +> 若用神不现,即以日月为用神,倘日月非用神者,则于本宫首卦寻之,因本宫首卦,父子财官六亲 +> 俱全之故耳。 + +位置随之而定:本宫首卦里那一亲在第几爻,就伏在本卦的**同一爻之下**;压着它的那一爻是**飞神**。 +书上给了两个例证,本包逐字复现——天风姤占妻财,甲寅木伏于姤卦二爻亥水之下;天山遁占子孙, +甲子水伏于遁卦初爻辰土之下。 + +飞伏之间的生克有四个名字,各自带断: + +| 飞伏关系 | 断法 | +| --- | --- | +| 飞来生伏 | 压着它的那爻反倒生它,作吉断 | +| 伏去生飞 | 伏神一味往上供,泄气费力,付出多而收成迟 | +| 伏来克飞 | 伏神一脚踹开压着它的爻,事来得突然而急,多为不吉 | +| 飞来克伏 | 飞神死死压住伏神,所求之事受制,难以出头 | + +伏神**出不出得来**是紧接着的一问。《增删卜易》列「易出有六」「终不得出有五」,本包**七条 +全部核验得了**——旬空、月破、入墓都按各自的表算。断出来只有出得来与出不来两种:旺衰只走 +旺相休囚死五档,旺相必入出、休囚死必入不出,两边已穷尽全部情形。 + +卦体上用神那一爻另用一枚**实底朱砂**标出(世应用描边框),一眼看出「这件事在这儿」; +伏神则用小字虚框挂在所属爻下,写作「伏 丙子水妻财」。 + +**元神 · 忌神 · 仇神 — 用神周围那一圈** + +取定用神只是头一步。《增删卜易》卷之一**用神元神忌神仇神章第九**把往后的话一并给了,还带金用神 +的worked example,断语照它逐条落: + +> 元神者,生用神之爻,即为元神。忌神者,克用神之爻也,即为忌神。仇神者,克制元神不能生用神, +> 反生忌神而克害用神,即为仇神。假令金为用神,生金者土也,土为元神;克金者火也,火为忌神; +> 克土生火者木也,木为仇神。余仿此。 + +三者都在本卦六爻里,一支也不从卦外借: + +| | 哪一支 | 作什么 | +| --- | --- | --- | +| 元神 | 生用神的那一爻 | 扶养用神 | +| 忌神 | 克用神的那一爻 | 打击用神 | +| 仇神 | 克元神的那一爻 | 间接为害,见下 | + +野鹤接着交代了该看什么,断语就照这三样逐爻报出——哪一爻、动不动、月建旺衰: + +> 既得用神,須看旺衰否?有元神動而生扶否?有忌神動而克害否? + +**仇神并不直接克用神**,措辞上要留神:它是压着元神叫元神生不动,转头又去生忌神,两头都替 +忌神出力。写成「仇神克用神」就把它说反了。这一点立成断言钉住:五种用神之下,仇神都要**反生忌神** +且**不生用神**,少一条都算破。 + +卦里根本没有的那一支就照实说没有——六爻只纳八个地支,五行里本就常常不齐,宁可写「本卦六爻里没有 +这一行」也不从别处借一爻来凑。用神定不下来时没有这一圈:用神取的是卦外伏神,或婚恋那样的两亲 +各看各的,都无从取舍。 + +同章还有一句本包照办: + +> 勿以仇神即仇人也 + +仇神是五行位置上的那一爻,不是卦里那个人。原书把话说尽了:它称作仇人的另有其人——应爻克世。 + +这一圈也把化爻那头一直只引文、没兑现的后半句接上了: + +> 凡遇回頭剋者,徹底剋盡,原用二神遇之則凶,忌仇二神遇之反吉也 + +有了这一圈,断语才说得出回头克究竟落在哪一支上:落在用神则凶,落在忌神或仇神反不作凶论,落在 +元神——书上没讲,就明说没讲,不替它定。四个方向各有断言:落在元神那一支**不许**被断吉凶,用神 +那圈压根不存在的卦**不许**硬接这半句。 + +卦体上三元各标在它自己那一爻,作小字描边,读作「元」「忌」「仇」;忌神用朱砂描边——它是那一圈里 +真在直克用神的那一支,元神与仇神则跟伏神一样用淡字。 + +**暗动 · 日破 — 静爻今天开始动了** + +上一节那一圈留了个问题没答,同一本书隔了几章给了答案。野鹤在列完元神、忌神、仇神之后问的是: + +> 既得用神,須看旺衰否?有元神動而生扶否?有忌神動而克害否? + +「元神有没有动起来生它?忌神有没有动起来克它?」要答这句,还得有第三种动——出自 +《增删卜易》卷一·暗动章第二十二: + +> 靜爻旺相日辰沖之爲暗動,靜爻休囚日辰沖之爲破。 + +一句话两半。**静**爻被日辰冲到,于月建旺或相的作**暗动**,休囚或死的作**日破**。这不是边角 +情形:六十四卦配十二个月建、十二个日辰、动爻的各种组合扫下来,约 **24%** 的卦里至少有一爻如此 +——暗动 10%、日破 14%——而所有逢冲的静爻中 42% 是暗动而非日破。此前本包把静爻一律当静, +「忌神暗动克害用神」「元神暗动生扶用神」这两件——正是这一章的正题——一件也报不出来。 + +| | 出处 | 怎么算 | +| --- | --- | --- | +| 暗动 | 《增删卜易·暗动章第二十二》 | 静爻,被日辰所冲之支冲到,于月建旺或相 | +| 日破 | 同章,下半句 | 静爻,被日辰所冲之支冲到,于月建休、囚或死 | + +章节号取**第二十二**,依卷一目录与传本页题。有二手站标作「025章」,那是全书篇序而非卷内章次, +不从。 + +只认**静**爻。被冲的爻若本就在动,那是冲散,归动散章第二十三,下一节讲,与本章分列;有断言钉住: +被冲的那一爻一动就从暗动与日破两路里退出去。 + +旺衰只有五档,两路分尽,不设第三种情形,也不留「无从判」的兜底。由此落出两条结构事实,都是验 +出来的不是假设的:日辰只冲一支,而两爻纳支相同(六十四卦里有二十二卦如此,如水雷屯初爻与上爻 +同子)时五行必同、旺衰必同,于是必同落一侧——**一卦绝不会既有暗动又有日破**。 + +**三处本包有意不跟宽法。** 原因就是这一章自己的卦例,本包的判语比那个卦例严: + +> 即如寅月乙未日占女痘得坤之師卦…二爻巳火動而克金,得未日沖動丑土,土動生金 + +这是坤为地、二爻发动,巳火克用神酉金子孙,未日冲动丑土,土遂生金。但丑土在寅月并不旺相, +落在**死**上——照章中字面它该是日破,原文却拿它当救应。实际救得成,是因为巳火是动爻而火生土, +也就是那条静爻得了动爻生扶。这一层细化是合理的,后世也普遍采用,但它不是这一章写下的判语;拿 +一个卦例去改定义,等于以个例改通例。本包守住章中定义,并在此说明。测试把整个卦例逐步复现—— +坤为地、世在上爻、应在三爻、二爻动变地水师、未冲丑、丑土在寅月落死——并钉死它作日破, +日后若有人按卦例放宽规则,测试会明明白白报出「定义与卦例已经不一致」。 + +忌的那一路有个异说。传本作「若遇忌神克害用神」,未系「暗动」二字;后世解说多作「忌神暗动克害 +用神」。本包取**带「暗动」**的读法——紧挨着的喜路明写「得元神暗動以相生」,且全章题为暗动、 +开篇即点「暗動者有喜有忌」,忌路若不含暗动,这一喜一忌就对不上。同段还有半句「忌神明動於卦中, +得元神暗動而生用神」自相矛盾:忌神按定义是克用神的,生不了用神。传本与后世多本都照录未改, +本包不据它另立一条。 + +断语把整条链摆出来:哪几爻暗动、落在元/忌/仇哪一行,再按书上给两路吉凶——元神暗动相生用神为 +喜,忌神暗动克害用神为忌。有两件事本包不做。用神定不下来时没有那一圈,就不挂吉凶。两路吉凶 +都写在「用神休囚」这个前提之下,用神旺或相时,断语会说这层前提并不齐备,而不是照套。仇神暗动 +两路都不占:这一章的喜忌只分元神与忌神,所以明说书上没讲这一支。 + +野鹤在本章末尾驳了通行的那句说,断语两句都引: + +> 占以暗動福來而不知,禍來而不覺。 + +> 吉凶之應於動,有急緩之應,則緩非此論,何當不知不覺,報應亦非緩也。 + +所以暗动不作迟缓读。卦体上暗动作小字「暗」,日破作「日破」,与空、破、墓同一个位置。日破 +与月破同族,用朱砂;暗动用淡字——它的吉凶要看这一爻是不是元神或忌神,单看卦体这一格并不知道。 +上面野鹤那两句驳的是「暗动迟缓」,所以只在真出了暗动时才摆;一卦若只出冲散,搬这句来就是跑题。 + +**冲散 — 被日辰冲到的动爻** + +紧接着的下一章,《增删卜易》卷一·动散章第二十三,一句就给了判语: + +> 占以日辰而沖動爻,謂之沖散。 + +**动**爻被日辰冲到,就是冲散。这是日辰冲爻的第三条落地路,而且比另外两条加起来还常见:同一批 +样本里,约 **24%** 的卦有冲散,暗动 10%、日破 14%。每卦平均 1.05 爻,最多两爻。 + +三路严格分开。判定先看动不动、再看旺衰,所以一爻绝无可能落进两路,被冲的动爻也绝不会同时被叫 +暗动或日破。暗动与日破另外还彼此互斥,理由上一节已经给了:纳支相同的两爻五行必同、旺衰必同, +必同落一侧。 + +**这一章自己的结论是「不散」。** 野鹤不是光下一个词名,他把自己反复试的结果写了进去: + +> 予屢試之,旺相者沖之不散,有气者沖之不散,休囚者間有沖散,亦千百中之一二也。 + +旺相的爻冲不散,有气的也冲不散;休囚的间或会散,也不过「千百中之一二」。末了落在 +「神兆機於動,動必有因」——机子在动上,而动必有因。所以断语**只报事实,不拿它断凶**:逢冲的 +动爻若于月建旺相,就点出这一爻并引「旺相者沖之不散」;任何情况下都不说它今日无力、更不据此 +断凶。这条有断言钉住——把那句话改写成「该爻今日无力,凶」,测试当场就红。 + +**豁免是别家给的。**《易冒·日冲章》拿四个字把范围收窄: + +> 如動爻遇日辰相沖,苟非月建,則謂之散。 + +「苟非月建」是豁免:被**月建**冲到的动爻不作冲散,那一路是月破,本包另有其段。所以冲散只由 +日辰决定。断言就钉在这一条上,而且钉成可能失败的样子:同一个日辰下换十二个月建,冲散那几爻 +一个都不许变;只被月建冲到、日辰没冲到的动爻不许进冲散那一路。 + +**章里有一部分本包有意不做。**「有气者沖之不散」点了第三种状态「有气」,而全章没说它对应旺衰 +五档里的哪一档。《四时旺相章》另有「冲月建之支按休囚、另支留余气」那一条,却没说余气落在哪 +一档。照搬要靠猜,猜出来的不是通例,所以只按旺相与休囚两半读。爻动冲爻则是另一回事,要判两 +爻谁强,也不在现有口径之内。 + +卦体上冲散作小字「冲散」,与空、破、墓、暗、日破同一个位置,用淡字加一句悬停说明——不上朱砂, +因为这一章的结论恰恰是**不散**。用神那一圈里,它是第四档「动而逢日冲」,与动、暗动、静并列: +早先只分动静两档,用神自己被冲散也只写成「动」,看不出来。用神圈与断语用神段这两处说法如今 +取自同一个共用函数,不会各说各话。 + +**六冲 · 六合 — 属于整卦的那种冲** + +上面这些,都是**日辰**冲到某**一爻**上的。紧接动散章的六冲章第二十,讲的是另一种:装卦本身就 +带着的冲。《增删卜易》卷一·六冲章第二十先把六组相冲的支点出来,再数有几种: + +> 子午相冲、丑未相冲、寅申相冲、卯酉相冲、辰戌相冲、巳亥相冲。相冲之法有六:日月冲爻者一也, +> 卦逢六冲者二也,六合卦变六冲者三也,冲变六冲者四也。动爻变冲者五也,爻与爻冲者六也。 + +第一路「日月冲爻」就是日辰月建对着爻,已经在暗动章第二十二与动散章第二十三里逐爻算过。剩下 +五路是这一节补的。第四路底本作「冲变六冲」,明天机一系写作「六冲卦变六冲」,指同一件事——变出来 +那个卦也是六冲卦——本包取补全的写法。 + +**配对是初四、二五、三六,数错了就一个都找不着。** 纳甲把内卦三支装在初二三、外卦三支装在四五六, +而两卦的起支错开一位(乾内子寅辰、外午申戌),所以一爻的对位是隔三位,不是同高。按同高去配, +六十四卦里一个六冲卦也出不来。按隔三位配,正好**十个**六冲卦、**八个**六合卦,与传世名单逐一 +对得上: + +| | 怎么来的 | 名单 | +| --- | --- | --- | +| 六冲卦 | 八纯卦,另加乾坤两卦互易的那两个 | 乾为天、坤为地、天雷无妄、坎为水、离为火、雷天大壮、震为雷、艮为山、巽为风、兑为泽 | +| 六合卦 | — | 地天泰、天地否、雷地豫、山火贲、地雷复、泽水困、火山旅、水泽节 | + +乾与震纳甲同支,所以天雷无妄与雷天大壮也是六冲。名单不是抄表,是从纳甲重新算出来再与这份 +名单逐个核;核不过就在模块加载时抛错,不会安安静静算出一份不同的名单。 + +**一组冲,三组必冲。** 世传歌诀说「这三组,只要有一组相冲,其他两组必定相冲,一看就知」。这 +不是经验,是装死了的:六支是一串平移的,六组相冲把十二支分成六对互斥的搭档,每一支的冲支唯一, +所以撞上一组就必撞三组。测试拿六十四卦把这一条逐个验过,模块加载时也拒绝在它不成立时启动。 + +**卦内有零散爻与爻冲,不等于六冲卦。** 六十四卦里有三十卦的两支爻互相冲,其中二十卦根本不是 +六冲卦。六冲卦是三对皆冲的整卦结构,零散爻冲是局部的事;混为一谈会把三分之一的卦都说成六冲。 +所以零散那几对本包只报事实,不抬到那个身份,也不为它单独开一段。 + +**章中的判语,以及本包不替人定的那两半。** 冲就是散,散对凶事是好事、对吉事不是——但原书紧接着 +自己收了一句「亦必兼用神而言,用神若旺,虽冲不碍;用神失陷,凶而又凶」。后一半判得了,断语就照 +用神说:用神定下来,旺或相说这层冲不碍着它,休囚死说这层冲对它不是好事;用神定不下来,就说这一层 +不接。另两半只引不套。所问算吉事还是凶事,是问卦人自己心里的定位,一句问题里读不出来,所以两半 +都摆上、不替人选;「近病逢冲即愈,久病逢冲则死」要看新病还是久病,只有问的人知道,所以占病只引 +这句、不替人选边。唯一接得上的是末了那半句「惟占官非、盗贼、结绝事者宜之」:所问既已认作官讼是 +非,这半句说的正是这一类,断语就接;不在此,则明说不是这一类,不替它改判吉凶。 + +章里对两路卦变都写得极重——六合变六冲是「先合后离、先亲后疏、先浓后淡、始荣终悴、得而复失、 +成而后败」,六冲变六冲是「内外变动,交相冲击,必主上下不和,至亲反目,彼此怀奸,始终不就」—— +可紧接着又拒不把六冲卦本身断死: + +> 古以六冲卦,诸占不吉。予屡试之,用神失陷,实不为吉;用若得地,须以用神断之。 + +所以六冲卦本身不作凶论,本包也不替它上色:卦体上「六冲」「六合」两枚小标都是淡字描边,不上朱砂。 + +**把配对画出来。** 「初四、二五、三六」是这一章里看图比读文字更省力的一条规矩:按同高去配, +六十四卦里一个六冲卦都出不来;隔三位去配,十个六冲与八个六合自己就落出来了。所以卦盘把它画 +出来——边上三支淡弧各接一对,一律朝外挂。画的是正文反复要强调的那件事:撞在一起的那两支永远 +差三行,绝不是并排的。 + +弧画在两栏之间那道空隙里,不画在卦面上。这一点是试出来的,代价不小:爻画那格是 1fr,凡是要给弧 +留的宽度都从它身上扣,而变卦里「回头生 + 应 + 六亲 + 干支」那一行本来就最宽,匀出一格给弧, +那一行的爻画就塌成零,阴阳都读不出来。挪进空隙里,卦面一格宽度都不占。 + +弧上不挂字。三对是哪三对,右栏「卦体冲合」那一格已经列全;再挂一份就成了要人对着核的第二份账。 +每支弧挂一个 `<title>` 写清是哪两支,读屏与查源码的人拿得到。 + +弧只出现在十八卦上,其余四十六卦一个标也不画。因为三对要么全撞、要么全不撞:全撞的正是那十个 +六冲与八个六合,不存在只撞一对的卦,也就无所谓「只画一条」那一路。模块加载时的自校验如今把这条 +也钉住了,不再只钉「一组冲则三组皆冲」。 + +**爻之合 — 合起、合绊、合好、化扶** + +六冲与六合那一节讲的是整卦的结构。六合章还有一层落在单爻上,本包原先没做,这轮补上。 + +章里明写「相合法有六」,六法又分两层:后三法(卦逢六合、六冲变六合、六合变六合)是整卦的 +结构,前面已经算过、也在卦体边上画了三支合弧;前三法(日月合爻、爻与爻合、爻动化合)逐爻落地。 +「爻之合者,静而逢合,谓之合起;动而逢合,谓之合绊;爻与爻合谓之合好,爻动化合谓之化扶」—— +四名由这一爻的动静与合的来路定: + +| 名 | 什么时候 | 原书那句 | +| --- | --- | --- | +| 合起 | 静爻合于日辰或月建 | 「得合而起,即使爻值休囚亦有旺相之意」 | +| 合绊 | 动爻合于日辰或月建 | 「动逢合而绊住,反不能动之意」 | +| 合好 | 两爻相合,且**两爻皆动** | 「乃得他来合我,与我和好相助之意」 | +| 化扶 | 动爻化出之爻回头与本爻相合 | 「得他扶助之意」 | + +合好有一句明文限定:**「但有一爻不动,亦不为合」**。所以静爻与动爻相合本包不作合好。 +章里后文另有「爻静或与日月动爻合者」一句,标点两读:读作「与日月、动爻合者」则静爻也能与 +动爻成合,这与上句正面冲突。本包从可核的一读,冲突那一支不取,理由记在代码注释里,不含糊过去。 + +合好只取不在初四、二五、三六上的配对——那三对属整卦六合,已经画在卦体边上了,在这里再记 +一次就是第二份要人核对的账。六十四卦里有二十卦另有这样的相合对(如雷火丰初爻卯与上爻戌、 +山水损二四与三五),合好就出在这二十卦上。 + +**这一层不定吉凶,这是原书自己收的。** 同章三处:「然必用神有气相宜,用若失陷无益」、 +「用神受克,六合有何益哉」、末了「宜合吉,不宜合凶」。所以断语只报关系与名目,吉凶仍旧归 +用神旺衰那一路;卦体上那个「合」小标与暗动、冲散一样用淡字,不上朱砂——颜色不该替没定吉凶的 +东西表态。 + +跨 365 日 × 256 种摇法(93440 卦)实测:约 **81%** 的卦会出这一段;合绊 52%、化扶 30%、 +合起 29%、合好 16%,平均每卦 1.97 爻逢合,最多一卦六爻全逢上。合起与合绊同卦出现的约 12% +(一卦里既有静爻被合起、又有动爻被合绊)。 + +**爻之刑 — 犯刑** + +三刑章第二十一:「寅刑巳、巳刑申、子刑卯、卯刑午、丑戌相刑、未辰相刑。又云:辰午酉亥谓之自刑。」 + +**照底本六条排,不照命理那套八条。** 两处实质不同:底本作「卯刑午」不作「卯刑子」;底本作 +「未辰相刑」一条,不拆成命理那边的「未刑丑、戌刑未」。命理那八条(多出申刑寅、戌刑未、未刑丑、 +卯刑子)在六十四卦上碰出 48 卦,底本六条碰出 28 卦。两套混排成十四条,「有几爻犯刑」这个问题就 +没有答案了——所以异说记在这里,不并进表里。 + +**刑有方向。** 丑刑戌在六十四卦上碰出 12 卦,卯刑午只有 8 卦。方向原样带出去,报「谁刑了谁」, +不折成一句「此爻犯刑」——折了就看不出是谁动的。 + +**自刑四支里,辰一支在爻与爻那路上一次也碰不着。** 这不是漏做,是结构上的事实:辰只装在内卦三爻 +(乾内子寅辰、坎内寅辰午、艮内辰午申),一卦只有一个下卦,所以一卦里最多出现一个辰,碰不出 +「本支见本支」。午、酉、亥三支都碰得出(各 8、6、8 卦)。辰这一支并不是死的——月建逢辰、卦里 +又有辰爻时,「月建自刑辰爻」照样成立,365 日里有 4.2% 的卦走这一路。模块加载时的自校验把 +「自刑辰在爻与爻那路上恒为 0」钉死,免得日后有人当成漏做再加回来。 + +三路都报:爻与爻相刑、本支见本支的自刑、日月与爻相刑。自刑本就对称,同一对只报一次—— +两头都收的话,断语会把「二爻亥自刑四爻」与「四爻亥自刑二爻」并排说一遍,看着像两件事。 +相刑那六条有方向,不受这条约束。书上那个卦例能逐字复现——寅月申日风火家人 +变离卦,月建寅刑五爻巳、日辰申被五爻巳所刑,两路正落在同一爻上(那一爻正是子孙)。 + +**这一层不定吉凶,而且依据是原书自己收的。**「夫三刑者,予屡试之,或因用神休囚又兼他爻犯之, +刑者则见凶,而独犯三刑得验者少,占过数十年只验得一卦。」野鹤自己试了几十年,单靠犯刑只验中一卦。 +所以断语只报「谁刑了谁」,再把「用神休囚」与「又兼他爻犯之」那两条前提逐条核一遍,成立不成立 +都照实说,吉凶仍旧归用神旺衰。卦体上那个「刑」小标与「合」一样用淡字,不上朱砂。 + +实测(数字起卦 19800 次):约 **90%** 的卦会出这一段;按六十四卦 × 365 日(23360 次)另扫一遍是 +90.7%,两者对得上。其中爻与爻那路 58%、日月与爻那路 80%,平均每卦 1.05 爻犯刑,最多三爻。 + +**有意没做的:卦身与世身。** 传统卦盘上多有这两样,本包一件也不画。不是漏了:本包底本是 +《增删卜易》,而删掉它们正是这本书自陈的特色——「删除卦身世身、星煞本命,使人无歧路之虞」。 +《卜筮全书》《卜筮正宗》录其名而不用,韩艺所传连三易又极重世身,后世分成三路。一个以《增删卜易》 +为骨干的包,再加一层骨干删掉的东西,等于跟自己的底本打架。判法记在这里备用——阳世从初爻起子、 +阴世起午,数到世爻那一支便是卦身;世身按世爻地支定爻位,子午居初、丑未居二、寅申居三、卯酉居四、 +辰戌居五、巳亥居六——但不画,也不拿来断任何事。 + +**反伏与卦变 — 同一件事的两头会翻面** + +反伏章第二十五开头就把两件事摆在一起,但它们各自的条件对不上,所以本包分成两档报,不合成一条: + +> 卦有卦變,爻有爻變。卦變者內外動而反伏者同一卦也。如乾卦變坤卦。爻變者內外爻動而反伏者,非同一卦也。 +> 如升之觀是也。又有外卦反伏而內卦不動者,如觀之坤是也。又有內卦反伏而外卦不動者如巽之觀是也 + +**反伏那一档:内卦或外卦的纳支,被整体换成与之逐位六冲的那一组。** 章里三例都对得上这一条—— +观之坤动五六,只有外纳支换了;巽之观动二三,只有内纳支换了;升之观动二、三、五、六,内外都换了。 +换过去的那一组,无一例外都是本卦那一组的逐位六冲(观之外卦巽的未巳卯换成坤的丑亥酉,一支一支都对得上冲)。 +本包照这一条判,内卦、外卦各判一次,所以「只内反」「只外反」「内外都反」三种情形分得清, +不用一句「此卦反伏」糊过去。六十四卦配六十三种非空动静共 4032 种,这一档落在 252 种上 +(内外都反 4、只内反 124、只外反 124)。 + +**卦变那一档:六爻全动,且本卦与变卦都是八纯卦。** 章里第一句举的乾变坤,纳支逐位**一个都不冲** +(子对未、寅对巳、辰对卯),所以它不属于上面那一档——它属于的显然是「同一卦」那四个字:乾坤、坎离、 +震巽、艮兑,两两相对。「全动」这一条不能省:乾只动初、四两爻也变得到巽为风,两头都是八纯卦, +可那一爻没动全,谈不上章里说的「內外動」。全量扫下来这一档八种,与反伏那一档**交集为零**—— +合成一条的话,乾变坤要么被漏掉,要么得为了它把反伏的判据放宽到不再可核。模块加载时的自校验把 +「两档交集为零」与「252 + 8 = 260 种」一并钉死。 + +**三档各有各的那一句,本包不替你择。** 内外都反伏时,章里「內卦反伏,我亂他定」与「外卦反伏,他亂我定」 +两句都沾得上,断语明说两句都对得上,不挑一句念。 + +**这一层不定吉凶,依据是原书自己给的。** 章末那句把话说死了: + +> 反伏卦用神旺相不變沖克者則反復,事之必成,第恐用神而化回頭之沖克者,卽是卦變大凶之象。 + +两条都以用神为轴,所以断语只把两条前提核出来——「用神旺相」那条核用神于月建的旺衰, +「用神化回头冲克」那条核用神本爻动不动、变出来的那一爻克不克本爻——成立不成立都照实说, +**「事之必成」不许当无条件判词**。取不出用神时第一条压根核不了,断语明说缺哪一层,不空谈。 + +**章里那十条分占只接所问事类对得上的那几条。** 占功名、占财物、占墳墓宅捨、占婚姻、占疾病、占盗贼官非、 +占出行各归本包一类事类(恋爱与婚嫁共用「占婚姻」那一条),「占彼此」那一路按内卦外卦分开引。 +占天时与占行人两桩本包没有对应的事类,接不住就明说接不住,不拿别的占法来顶。 + +实测(手掷铜钱):出这一段的约 **6.4%**,档位分布为内卦 47.7%、外卦 47.7%、内外都反 1.5%、卦变 3.1%。 +**时间起卦、每日一卦、数字起卦三种起法出这一段是 0**——不是漏做,是那三法照梅花易数本来的规矩只动一爻 +(动爻由总数除六取余,只有一爻),而反伏至少要动两爻、卦变要六爻全动,条件上就碰不着。 +实测 9125 次,零命中;六十四卦 × 63 种动静 × 31 个采样日共 124992 次,6.4%。 + +**有意没做的:卦体上的反伏小标。** 空、破、墓、暗、日破、冲、合、刑都挂在单爻上,反伏与卦变挂不了—— +它们说的是本卦与变卦之间的关系,不是某一爻的处境,挂到单爻上会指错地方。所以只进断语与 MCP 抬头。 + +**六神 — 日干说出的这件事的调子** + +六爻卦盘上人人都会画的那一列。《卜筮全书·卷之一·启蒙节要》: + +> 甲乙起青龍,丙丁起朱雀,戊日起勾陳,己日起螣蛇,庚辛起白虎,壬癸起玄武。(俱從下起至上。) + +日干定的是**初爻**起哪一神,自初爻往上按固定次序转:青龙、朱雀、勾陈、螣蛇、白虎、玄武,转满 +六个再回头。歌诀原文入库,与紧随其后的六行排列表逐格对撞——甲乙、丙丁、庚辛、壬癸两两同干, +共三十二格。同章的两个乾为天卦例也逐爻对过:甲子日子水子孙临青龙,戊子日同一爻临勾陈。 + +| 六神 | 五行 | 所主 | +| --- | --- | --- | +| 青龙 | 木 | 喜庆、喜事、贵人、酒色、正直 | +| 朱雀 | 火 | 口舌、文书、消息、是非、诉讼 | +| 勾陈 | 土 | 田土、房产、牵连、迟滞、牢狱 | +| 螣蛇 | 火 | 怪异、虚惊、缠绕、噩梦、欺诈 | +| 白虎 | 金 | 凶险、血光、伤病、丧事、威猛 | +| 玄武 | 水 | 暗昧、盗贼、隐私、暧昧、欺瞒 | + +螣蛇的五行有异说,通行本多作火,亦有作阴土者。本包取**火**并写明:取土则与勾陈同,而 +「虚惊」与「迟滞」本是两件事,取错便说反了。 + +有一条本包不许越的界,断语每次提到六神都把这句摊开: + +> 吉凶全凭五行生克,情态方看六神吉凶。 + +六神**不参与生克、不改吉凶**,它只说这件事是什么性质、什么气氛。测试拿同一个卦在二十八天里 +各起一次卦——初爻所临之神换过好几轮——吉凶必须一个字都不动。 + +**化爻 · 变出之爻 — 动爻往哪儿去** + +京房这一层的最后一块:动爻不是它自己了,它在变卦里变出另一支纳支。变出来的这一支与原来那支 +是什么关系,就是化爻的全部。 + +| | 是什么 | 怎么论 | +| --- | --- | --- | +| 回头生 | 变爻生本爻 | 吉 | +| 回头克 | 变爻克本爻 | 凶 | +| 化泄 | 本爻生变爻 | 只陈事实,不配吉凶 | +| 化耗 | 本爻克变爻 | 只陈事实,不配吉凶 | +| 化比和 | 同行 | 只陈事实,不配吉凶 | + +两个名目由原话钉死,而两个方向分得清楚: + +> 巽木变坎水,谓之化生,水回头以生木也,即以吉断。 + +> 震木变乾金,谓之化克,金回头以克木也,即以凶推。 + +两句都只看一个方向——**变爻对本爻**。《卜筮正宗·十八问答第二问》把回头克的五种情形逐个点出来: + +> 土爻動而變木、木爻動而變金、金爻動而變火、火爻動而變水、水爻動而變土,此是爻之回頭剋也 + +正好落在「变爻克本爻」的全部五个五行组合上,一个不多一个不少。这张表在本包里是当**定义校验** +用的(`HUI_TOU_KE_PAIRS`),模块加载时逐对与生克表对撞,定义不能与代码走偏。同章还有一句, +它让回头克不只是个平铺的凶: + +> 凡遇回頭剋者,徹底剋盡,原用二神遇之則凶,忌仇二神遇之反吉也 + +所以断语每次都把「彻底克尽」这半句带出来:回头克落在用神身上则凶,落在忌神仇神身上反吉。 +另三类**不配**吉凶——《增删卜易》给了名目却没定吉凶,硬配就成了编。 + +**进退神**是它在地支那一层的搭档,出自《增删卜易·进退神章第二十九》: + +> 进神:亥化子,寅化卯,巳化午,申化酉,丑化辰,辰化未,未化戌,戍化丑。 +> 退神:子化亥,卯化寅,午化巳,酉化申,辰化丑,未化辰,戍化未,丑化戍。 + +十六对、两个方向,每对同行、互为反面。其中四对在土上(丑→辰→未→戌→丑,这圈上前后各走一步), +这是歌诀本来就密,不是笔误。歌诀里没有的不判。变爻自身的化空、化墓只作**事实**报告,不配 +吉凶:野鹤讲空讲的是「动爻逢空」那一层,书上并没有「变爻逢空即凶」这样的断语。 + +这一层有一条规矩把边界划死,断语在给出任何结论之前先把它摊开: + +> 夫變出之爻,能生克沖合本位之動爻,不能生克他爻,而他爻與本位之動爻,亦不能生克變爻。 + +变爻只跟本位动爻相生克,不跟他爻相干,也不跟世爻应爻攀关系。还有一条容易踩的:变爻在变卦里 +是**静**的那一爻,不许把「发动」「得动爻生扶」这些救应算到它头上——它占的爻位恰好就是动爻 +那位,一混就会把本该落的真空悄悄翻成假空。 + +**旬空 · 月破 · 入墓 — 这一天的日子对每一爻做了什么** + +这三样压在京房的纳甲之上,决定一爻今天还算不算数。 + +| | 出处 | 怎么算 | +| --- | --- | --- | +| 旬空 | 《增删卜易·旬空章第二十六》 | 歌诀六句原文入库,与算法逐句对撞。天干十位配十二支,一旬十日总有两支配不上天干,那两支便是空。 | +| 月破 | 《增删卜易》「月破者,月建冲爻之谓」 | 月建冲本爻,与「正月申破」到「十二月未破」的月表一致。 | +| 入墓 | 《纳甲筮法讲义·生旺墓绝》 | 取自墓一支:金墓丑、木墓未、水土墓辰、火墓戌。 | +| 绝地 | —— | 不出结果,且不是漏做。绝是墓的下一支(金绝寅、木绝申、水土绝巳、火绝亥),可纳甲里每个五行只占两支——金申酉、木寅卯、水子亥、火巳午、土丑辰未戌——上列绝支没有一支落在五行自己占的支里,六十四卦任何一爻都落不上。「绝」这一标据此撤掉,而不是留一个永远不会亮的字;`jingfang.mjs` 加载时核这个事实,将来一旦不成立就在这里抛。 | + +空有真假,野鹤自己分得清楚,断语照他的原话逐条落,不另立规矩: + +> 旺不爲空,動不爲空,有日建動爻生扶者不爲空,動而化空、伏而旺相皆不爲空。月破爲空。 +> 有卦不動爲空,爻反伏而被克爲空,真空爲空,真空卽春土、夏金、秋木、三冬逢火是真空。 + +**假空**是这个空不作数:旺、动、得日辰或动爻生扶、动而化空、伏而旺相任一条都能救它, +出旬或逢冲之后照旧有力。**真空**是这一旬里真没用:月破,或逢四季所空的那一行 +(春空土、夏空金、秋空木、三冬空火),或伏而被克。卦体上分得清——假空作「空假」,真空作「空真」。 + +有一处取舍照实说明,不藏着:野鹤原文作「有卦不動爲空」,后世多本作「有氣無動爲空」, +差一个「氣」字,意思是「静卦全空」还是「静而有气者空」两样。本包取**后者**—— +同段前一句「旺不爲空」若被推翻便无处容身,取前者自相矛盾。 + +每一爻在卦体上各有一个小标:空、破、墓、暗、日破、冲散;右栏另有一格把当旬旬首、空亡两支与本月所破 +的地支直接摊开。 + +只逢月破、不逢旬空的爻同样作真空,依据就是同段那句「月破爲空」。早先这里出的是「暂看不出真假, +等出旬或逢冲之日再定」——两处都不对:书上明写的一条凭据被搁下了,而「等逢冲」更是与月破章 +正相反: + +> 雖現於卦,有亦如無;伏於卦中,終難透露。即有日辰之生,亦不能生。 + +所以月破这一句改成「待出月、逢值再论」,不再提逢冲——出月、逢值才救得回来,冲只会伤得更重。 +只逢旬空而不月破的爻仍是「等出旬逢值或逢冲再论」,那一路的空,冲一下确实能解。 + +**卦气 · 当令主卦** — 当月该由哪一卦说话 + +汉易以十二卦配十二月,称十二辟卦:复主子月、临主丑月,直到坤主亥月。它与「旺衰应期」 +那条不是一回事——那条看的是五行在月令里强弱,这条看的是卦象随节气的消长。复至乾是息卦, +阳爻自初爻逐次上升;姤至坤是消卦,阴爻自初爻逐次上升。一年就这么转一圈。 + +断语会点出本月主卦,并说明本卦是否正在十二辟卦之中:若本卦也在息卦那一段,方向与时势 +相合;若分处消长两端,就是逆着时势走,宜放缓一步。十二辟卦只占六十四卦中的十二个,其余 +五十二卦不论卦气——不硬套。 + +**消长环** — 上面这套理论画出来是什么样子 + +十二辟卦是抽象的,一个环把它摊开:环分十二格,每格一卦,每格画 N 条短杠,N 就是那一月 +长了几爻。复一爻长到乾六爻是阳气到底,折回姤一爻再长到坤六爻是阴气到底,走完一圈是一年。 +走到对宫就是极盛——子午、卯酉那两条虚线就是标这个的。 + +当月那一格高亮;若你这一卦正在十二辟卦之中,环上会点一个朱砂点标出它的位置。环放在断语 +之前:断语是结论,这个环是结论背后的时令坐标,先看得到形状,再读「当月主卦是哪一卦」, +就不至于只是一句话。系统开了「减少动效」时,当月格的呼吸动画会停掉。 + +**应期** — 事情何时应 + +取类神(认出事类时)或用卦(未认出时)五行的旺相之地。乾金为用卦,旺在申酉、相在亥子丑, +断语便指向申酉月与申酉日。 + +**爻位之象** — 动爻位置的含义 + +初爻事在萌芽、位未显;二爻近身而仍受制;三爻内外之交,是进退转换之关;四爻近人多惧; +五爻君位,多见贵人;上爻事至终局。 + +**方所** — 方位 + +取体卦与用卦的后天八卦方位,问寻物问路可取。 + +体卦得势但用卦耗你时,断语不会只给一个干脆的「吉」,而是补一句「方向可进,力气要省」。 + +**卦库 — 六十四卦** + +可按卦名或上下卦检索,查看卦辞、彖传、象辞与互卦、错卦、综卦。 + +**历法** + +年月日时四柱干支、十二时辰及其黄道黑道、建除十二神、二十四节气、数九、十二生肖三合六合。 + +**卦历** + +保存的卦象存于本机,起卦后可补一句批注,按时间倒序,最多 500 条,可单独删除。写了所问就用所问 +当卦题,没写则按起法命名(今日之卦 / 此刻之卦 / 心中之卦 / 手掷之卦)。 + +**在对话里起卦 — MCP 端点** + +本包同时向 Agent 提供一个 MCP(streamable-http)端点 `/mcp/divination`,可以不打开页面直接在对话里 +起卦,三个工具分别是 `divination_cast`(起卦解读)、`divination_hexagram_lookup`(检索六十四卦)、 +`divination_almanac`(今日干支历法)。 + +**本包自己不调任何模型、不出站。** Agent 本身就是模型,由它调用本端点、再用自己的语言讲卦—— +密钥、计费与上下文都留在会话里,本包只负责「算得对」。 + +参数、返回格式、怎么触发、排障:见上面的[在对话里起卦(MCP)](#在对话里起卦mcp)。 + +## 安装 + +将本目录(含隐藏的 `.minimax-plugin/`)复制到 MiniMax Code 插件目录,命名为 +`chinese-divination/`(默认为 `~/.minimax/plugins/chinese-divination/`,具体路径见仓库根 +README 的安装说明)。重启 MiniMax Code 后对 Agent 说「打开灵签易占」。 + +## 测试环境 + +- 有一份脚本冒烟测试:拿一个临时 `dataDir` 在回环上真起本包的 `start(context)`,从头跑一遍—— + 页面、四种起法、掷钱、六十四卦全表、历法、卦历增删查、同秒起卦 id 撞车(从前删一条会连带删 + 另一条)、故意写坏的 `readings.json`,以及 MCP 端点的 `initialize` / `tools/list` / 三个 + `tools/call` / 通知 / 未知工具 / 非法事类各条路径。同时断言 `Host` 与 `Origin` 两道闸确实挡住 + 非回环名——这一项走裸 HTTP 请求驱动,因为 `fetch` 会把调用方给的 `Host` 静默丢掉;还断言 + `dispose` 真的关掉监听且可重复调用、日志里没有 `dataDir` 路径也没有操作系统用户名、跑完之后 + `dataDir` 里只剩卦历文件。macOS,Node 22.23.2:83 项全过。 +- 同一套测试在四个时区下各跑一遍(`America/New_York`、`UTC`、`Asia/Shanghai`、`Pacific/Kiritimati`, + 跨 UTC-8 到 UTC+14),另加 `LC_ALL=C LANG=C` 极窄 locale 一遍。每种组合都是 228 通过、0 失败, + 即此处不依赖本机时区与 locale。 +- 一份路径审计扫全包,0 问题:每个路径段都是 ASCII 且符合可移植路径规则,没有段撞 Windows 保留 + 设备名,全部文本文件 UTF-8、无 BOM、无 CRLF,没有两个文件只差大小写,每条相对 import 都能在磁盘 + 上找到,最坏形态的 `dataDir` 全路径 112 字符,离 `MAX_PATH` 还远。 + +- **Windows 与 Linux 均已实跑。** 三个平台都真跑过,不是只做推演。macOS 那一轮是上面写明每一步 + 的脚本化冒烟;Windows 与 Linux 上装好后经 Agent 打开,四个分区逐一走过,掷钱、卦历与 MCP + 各条路径一并查过。静态核查垫在这轮实测底下,两条平台相关的设计决定也建立在它之上:路径一律 + 用 `node:path` 拼(`join` 会把 `miniapp/client/index.html` 里的正斜杠在 Windows 上归一成反斜杠, + UNC 路径也正确);源码里没有硬编码的分隔符或盘符;没有 `__dirname`(ESM 里本就不存在), + `import.meta.url` 只出现在测试文件里,且是 `new URL(相对, import.meta.url)` 加 `fileURLToPath` + 这一 ESM 跨平台正解,而测试文件本就在运行时载荷之外;三个落盘文件名(`readings.json`、它的 + `.tmp`、它的 `.corrupt-<时间戳>`)都是 Windows 合法名,不含保留设备名与保留字符;按最悲观的 + dataDir 形态模拟,最长的那个到 139 字符,离 `MAX_PATH` 还远;源码里两处路径字面量与磁盘上的 + 拼写逐字符一致——这一点 macOS 上就算对不上也照样能跑,Linux 不会;全部文本文件 UTF-8,无 BOM, + 无 CRLF。 +- **唯一在三个平台上都没核过的一项:** 760px 断点以下的版式未在实机核对。收窄内容区替代不了—— + 下面那两处容器查询反应的是区块自身宽度而非视口宽度,只有真实窄视口才走得到那条路。 +- MiniMax Code 3.0.73,macOS,Node 22。从本目录安装,经 Agent 打开,页面正常渲染,四个分区 + 均可操作。MCP 端点 `/mcp/divination` 的 `initialize`、`tools/list`、`tools/call` 与错误路径 + 已在本地实测。 +- 版式与字号本轮整体上调了两档,随后在 1524×1304 视口下逐页目视核对:起卦、解读(卦体行、 + 四卦推导、卦气消长环、八宫名单)、卦库、历法(十二时辰、二十四节气、十二生肖)、卦历 + (存了一条 40 字长问题的条目),均无折行与溢出,浏览器控制台无报错。 +- 卦画的可读性立了两条断言,这两条都曾经是真的、远看只是一团灰:阴爻不许再用 + `--border-strong`(只两成黑,当初是为了让阴爻「退后」),爻画也不许退回 4–5px 的发丝粗。 + 阴阳本来靠形状分——一整条对断成两截——明暗再掺一脚只会把卦画弄糊;解读页那副主卦体 + 的粗细下限也比旁边的小卦图更高。 +- 十二时辰的网格立了常驻断言:列数必须整除 12,时间与神煞两行不许折行。这条曾经真发生过—— + `auto-fit` 轨道会悄悄把格子挤窄,`03:00 - 05:00` 折成两行,同一排卡片便高矮不齐。 +- 另有三处版式缺陷立了断言,都是实机看出来的、不是推演出来的。八宫名单那一行顶对齐, + 唯独本卦那一格为了朱砂框带了 border 与内边距,于是只有它被顶下去 5px,看着像「掉下去」; + 框改成 outline,画得出来又不占位置。消长环的爻杠伸得比标签挂得还远,那两个满六爻的格子 + (乾巳、坤亥)爻杠正压在「乾」「坤」的字上;标签退到 R+40,画布同时从 200 扩到 232 见方 + 才腾得出位置。环的呼吸动画原先把当月格压到 0.55 透明度,等于每 2.6 秒把它指的爻杠闪到 + 看不清一次,现在最低只到 0.8。 +- 解读页两栏原本右栏远长于左栏,滑下去左侧便空出一大片。现把标题与四个分区合成一条钉住的 + 顶栏(半透明底加模糊、下缘一道分隔线),左栏在宽屏且视口够高时钉住并按顶栏高度错开。 + 顶栏高度原先写死在 `:root`,实测发现那是 1340px 宽下的值:把内容区收窄到 700px,顶栏里 + 标题、副题与干支条排不下就分两行,顶栏长到 195px 上下,左栏仍按 132px 错开,「天风姤」 + 卦名直接被压在顶栏底下。高度改由 `ResizeObserver` 实测写回,`--topbar-h` 只作首屏兜底, + `:root` 里那个值不再是两个地方各写各的数。 +- 四卦推导与八宫名单改成按自己这块有多宽排版,不按视口。两块原先都是 flex 换行:内容区一收窄, + 推导那四格折成 3+1、八格折成 7+1,末格孤零零居中,像是掉了出去。现在各自是一个具名容器 + (`container-type: inline-size`),列数由 `@container` 定——推导四列或两列,八宫八列或四列, + 列数一律整除总数,不留落单的一行。两块都在右栏里,右栏多宽由左栏与版心决定,跟视口不是一回事; + 拿视口宽度定列数,就会出现宽屏配窄右栏、排得参差的情形。有一条断言反向钉住:任何 `@media` + 规则都不许碰这两块,列数才不会又漂回视口上。 +- 推导图里箭头与卦裹成一格,不再是平级的两项。互卦那条注记两行、其余各一行,箭头原先是垂直居中 + 的 flex 列,于是它比另外三支低半行,四支不在一条线上。现在注记是上下居中的 flex 盒子、定高两行, + 本卦那一格横跨整行居中而不是落在第 1 列;断行由数据里的 `\n` 决定(`white-space: pre-line`), + 不看 `max-width` 恰好折到哪儿。那个宽度原先是 88px,而最长的注记分段是七个汉字,按注记 13px + 算要 91px——于是互卦那条被折成三行,箭头又离了线。断言逐段量一遍:盒子窄过字要占的宽度就报红。 +- 版心内边距收成一个值。`.app` 原先左右各写死 32px,在 1149px 视口上白白吃掉 64px 宽度,推导图与 + 八宫正是被这一点挤到换行的。现在是 `--app-pad: clamp(14px, 2.1vw, 32px)`:`.app` 用它,`.topbar` + 用它的相反数——两处各写一个数,内边距一收放顶栏底色就不贴视口边。顶栏的外边距与内边距、解读页 + 两栏之间的缝、左栏的宽度,都断言跟着这一个值走。 +- 左栏一度被收窄到 `clamp(232px, 21vw, 366px)` 想给右栏腾地方,那是错的。爻行那一格是 + `34px + 42px + 1fr + 32px + auto` 再加四道 12px 的缝,固定部分先吃掉 156px,留给纳甲(干支、六亲、 + 世应、旬空月破,全是 nowrap)的那格还得有 140px 出头。窄过 300px,纳甲就撑破格子探到右栏去, + 爻画同时被压没——阴阳一眼就读不出来了。现在是 `clamp(300px, 26vw, 366px)`,下限照着爻行真正 + 需要的宽度钉死。`minmax(clamp(...), 366px)` 先试过,是空转:上限是死数,内容只要够宽轨道就一直 + 顶在 366px,下限永远轮不到。 +- 本轮新增的六项断言都做了变异验证:两条容器查询、四处列数、格子包裹、注记盒子、版心单一来源、 + 左栏下限,以及「视口查询不许碰这两块」那条反向断言——逐个把实现改坏,确认对应断言真的报红, + 27 条全部钉住,脚本跑完还原后 212 项全过。写脚本时又踩了两个坑,其中一个是真把测试写松了。 + 脚本原先拿每条变异的预期断言去比**用例标题**,而预期写的是断言消息,于是一轮全部正确的运行 + 报出 26 条没钉住;现在改成读失败详情。窄屏那条断言写的是 `grid-template-columns: 1fr`, + `1fr 1fr` 照样匹配——测试实际在替两栏那条作证,却声称钉的是单列;补上收尾分号才分得开。 + 只在 `finally` 里还原是第三个坑,上面那条已经记过:第 N 条变异会跑在第 1 条改坏的文件上, + 看着像「测试没钉住」,实为污染。这一轮的每条变异动手前都先从干净基线还原。 +- 通盘审下来又挑出三处缺陷,都在卦历落盘与起卦 id 上,现已修掉并各自钉住。原先的 id 是秒级 + 时间戳加卦序、动爻一起哈希。数字起卦只取决于那两个数,所以同一秒内起两次同样的数会得到 + 分毫不差的同一个 id——而起卦按钮在整个推演动画(要停 `CASTING_HOLD_MS` 那么多)里一直可点, + 普通双击就真的会发出两次请求。两条都存进卦历,之后 `remove(id)` 按 id 过滤,删一条连带把另一条 + 也删了:实测存入两条同 id 记录、删一次、剩零条。现在 id 的种子掺了一个进程内单调递增的计数, + 断言钉的是后果而不是某个字面串——同一秒内同一卦的两次起卦 id 必须不同,删一条必须留下另一条。 +- Windows 上把文件改名覆盖到已存在的目标,若目标正被别的进程以未共享删除权限的方式打开 + (杀毒扫描、搜索索引都算),会整个失败并报 `EPERM` 或 `EACCES`——不是半截写入,是压根没换, + 这次保存就这么丢了。现在遇到 `EPERM`/`EACCES`/`EBUSY` 退避重试至多四次(累计约 400ms), + 其余错误码直接抛:磁盘满、只读盘重试多少次都一样,只会白吊住这一次请求。判断抽成纯函数, + 免得为了测它真去制造一次文件锁;调用点另钉一条——把用它的那个循环删掉,纯函数照样全绿。 +- `readings.json` 解析不出来时,从前是从每个卦历接口一路抛出去,于是写到一半被截断、被手工改过 + 或被同步软件截断之后,卦历就永久打不开,用户连自救的入口都没有。现在把坏的那份改名另存为 + `readings.json.corrupt-<时间戳>`——挪开而不是删掉,里面手写的批注捞得回来——卦历按空表继续走。 + 另外 `ReadingStore.update()` 没有路由、没有调用方、也没有测试,它唯一产出的 `updatedAt` 被 + `toSummary` 抄了出去,成了一个恒为 `undefined` 的死字段。两处一并删掉,而不是留一条永远走不到的 + 分支和一个让人以为存在「改批注」功能的键。`GET /history/:id` 保留:它对应的 store 方法有测试 + 覆盖,也是这条资源的自然形态。 +- 这三处新增的十五项断言都做了变异验证,15 条全部钉住,脚本跑完还原后 212 项全过。第一跑有三条 + 报没钉住,逐条看下来三处都是脚本的错而不是测试松:一条 expect 指向的断言在更早的一条已经 + 报过红,一条变异把更前面的断言已经罩住的调用整个删了,还有一条在走到点名契约的断言之前就把 + 用例掀翻了——现在测试兜住这个拒绝并把原因折进消息里,报出来的是被打破的契约而不是一句 JSON + 解析错。顺带再记一条:`assert.match` 与 `assert.equal` 不带消息时抛的是 Node 的默认文案, + 任何按关键词判「这条变异钉住没有」的检查都对不上。下面两条断言为此写了显式消息。 +- 起卦按钮全程没有在飞闸,这是上面那条 id 缺陷的另一半。推演那一拍要停 + `CASTING_HOLD_MS`——四秒多一点——其间四个起法按钮与「起卦」按钮一直可点,普通双击就会 + 连发两次请求,两卦的推演日志互相盖着打字,谁先回来就以谁为准。那个面板里原本只有一个 + `disabled`,长在 `toss-finish` 上,而它管的是另一条规矩(要摇够六次),不是「正在起卦」。 + 三条起卦路(每日/时间、数字、铜钱)都走 `cast()`,所以闸就设在那一处:一个 `casting` + 标记,在第一个 `await` 之前同步置上,期间把触发控件一并锁住。 +- 还原是最容易写错的一环,所以单独钉住。解锁按各控件原值放回,不是一律清掉 `disabled`: + `toss-finish` 在没摇够六次时本来就该是禁着的,一律解禁会留下一个看着能点、点下去什么也 + 不发生的「成卦解卦」。解锁放在 `finally` 里——一次请求失败不该把整个起卦页永久锁死。 + `cast()` 另外返回「这一卦到底起成了没有」:数字那条只在成功时才清空两个输入框,被锁跳过 + 或起卦失败时清空,等于替用户把刚填的东西扔了。 +- 下面是实机验的,不是读代码推的:双击「时间起卦」,网络里只出现一次 + `POST /api/divination/cast`;推演结束后 `toss-finish` 仍带 `disabled=""`,四个起法按钮不带。 + **推演那四秒中途的禁用态没直接观察到**——工具调用之间的往返比那个窗口还长——但那句赋值 + 与双击测试证明确实生效的 `casting` 标记在同一个同步块里。覆盖它的十二项断言都做了变异 + 验证,12 条全部钉住,脚本跑完还原后 212 项全过。第一跑有两条落在脚本头上而不是测试头上: + 一条变异改完不影响任何行为,是空改动;另一条把整个 `finally` 删了,于是撞在更早那条 + 「没有 finally」上,而不是它本该命中的那条。 +- 摇钱那两个按钮原先的状态是互相打架的。`renderToss()` 按「摇到第几次」算出该禁哪个禁哪个,紧接着 + `tossOnce()` 的收尾又硬写了一次解禁,把刚算出来的「摇够了,掷钱该禁」盖回可点——于是摇满六次 + 之后两个按钮同时可点:既能再摇出第七爻(那一爻排不进六爻的卦里,也撤不回,卦上多一爻解读就跟着 + 偏),又能就着六爻成卦,两头都不作数。现在两个按钮的可用性收进 `syncTossButtons()` 一处,只由 + `state.tosses.length` 说了算;摇钱途中的收尾调它重算,而不是一律解禁。这跟上一轮起卦锁的还原是 + 同一个道理:解锁要按状态算,不能无脑清 `disabled`。 +- 顺带补上两处同族的。一是摇钱在飞时按住「重来」——飞到一半点重来,已经在飞的那一爻会落进一个刚被 + 清空的列表,卦里就多出一爻看不见来源的东西。二是摇满六次后点「重来」这条不经过任何收尾的路径: + 按钮状态必须由重画本身算出来,不能指望上一次收尾顺带带出来。 +- 这一段的测试是**真跑**这段逻辑的,不是读源码文本:抠出 `syncTossButtons`/`lockToss`/`tossOnce`/ + `renderToss` 四个真函数,配一套假 DOM 与假 fetch,把静置、摇一至五、摇满六次、摇第七次、重摇、 + 在飞、请求失败七条路都走一遍。坏在「谁最后写 `disabled`」上,源码文本看不出谁盖谁。三十六项断言 + 都做了变异验证,15 条全部钉住,脚本跑完还原后 212 项全过。第一跑有七条落在脚本头上:我押的 + expect 是后面那条断言,测试实际红在更早的同一条语义上——比如把「成卦解卦永远解禁」改成 `> 6`, + 静置态那条就先响了。**还有一条是真把测试写松了**:原先拿卦象日志的 DOM 条数去量「失败时有没往卦里 + 记一爻」,可失败时 `renderToss` 压根没跑,日志当然是空的,量了个空;改成直接量 `state.tosses.length`。 +- 上面这一段也是实机验的:起一次临时验证进程,在浏览器里点满六次「掷钱」,`#toss-btn` 确实带上 + `disabled=""`、`#toss-finish` 确实解了禁;再点「重来」,两者精确回到起点(一可点一禁着)。 +- 起卦那一拍原先只播引擎给的取数步骤(摇卦两步、数字起卦三步),一卦没成话就说完了。现按 + 要紧程度补上成卦之后的几句:上卦下卦与本卦名、动爻与变卦、宫与世应、体用五行,四法都是四句, + 用的全是 `reading` 上已算好的字段。预算紧时先丢体用句,成卦、变卦、宫世应一句都不许被挤掉。 + 这条漏过一次:收尾那句的字数原先没算进预算,字一多就把实际时长顶出预算,末行被切页砍掉; + 现在收尾先占额度,且停留时长取自常量 `CASTING_HOLD_MS`(推演预算 4s 之外留 400ms),调用点 + 不再另写一个数。四法实测推演 3904–4000ms、停留 4400ms。日志容器定高 320px,够放播报上限的 + 11 行(10 行受限 + 1 行收尾),少一像素都不成——那行是 nowrap 定行高的。 +- 本轮新增的十四项断言都做了变异验证:顶栏六项(钉住、模糊底、分隔线、排层、抵消内边距、包住 + 分区),左栏五项(钉住、错开量跟着顶栏走、限定视口宽高、高度实测回写、不写成死数),播报 + 四项(四类都在、更要紧的三句不被挤、收尾留得住、定高够 11 行),时长两项(不顶穿预算、停留 + 取自常量)——逐个把实现改坏,确认对应测试真的报红,22 条全部钉住,脚本跑完还原后 212 项 + 全过。写脚本时踩过一个坑:只在 `finally` 里还原,第 N 条变异会跑在第 1 条改坏的文件上, + 锚点莫名 0 次、报红数一路递增,看着像「测试没钉住」,实为污染。 +- 本包自带测试(在本目录执行 `node --test "tests/**/*.test.mjs"`)覆盖六十四卦表、错卦/综卦/ + 互卦推导、干支锚点、十二神、建除、数九、摇卦规则、各法节律、五行类事与存储读写。经典文本 + 逐条对校:三百八十四条爻辞与卦象核对,三百八十四条小象传与其爻题核对,六十四条彖传与卦表核对,十二辟卦与月支、卦表双向核对,四卦推导按各自取法核对,起卦时长与推演预算核对,查卦默认档位与显式要原文的两种返回都核对。用神另核四项:九个事类的取法逐条对出处、择爻次序、婚恋分男女不硬择、不上卦不编。京房一层另核九项:八宫卦序与传世表逐一对撞、归魂与游魂的变爻集合、纳支歌诀八条、纳支随经卦阴阳、六亲配法、世应配对不越界、断语与卦体的调用点、查卦两档返回、主客恒分居内外两卦。伏神另核五项:书上两例逐字对上、六十四卦全量扫描确认每处缺失六亲都取到唯一伏神且**出自本宫**、飞伏生克四名、出伏条件交代哪几条做不了、右栏报出伏在哪一爻、MCP 响应把它落成字段。旬空月破入墓另核十三项:旬空歌诀与算法对撞、书上两个卦例由日柱反推出书中所说的空亡、月破十二个月逐月核对、五行入墓、四季之空、假空的有救与真空的凭据、出伏在纯靠空时真落到「终不得出」、休囚无气那一条、空破墓各自到卦体与右栏,以及单逢月破也作真空、月破不再劝人等逢冲、「逢月破」这个理由不与标记重说一遍。六神另核四项:歌诀逐字与原文相符、六行表三十二格逐格对撞、化爻另核九项:回头生与回头克的两个原话方向、二十个五行有序组合各归五类且每类五个、《卜筮正宗》回头克五例与生克表对撞、另三类不带吉凶调子、十六对进退神歌诀逐字与同行与反向互证、只认本位动爻那条引文、变爻按静爻问(同一爻误记为发动就翻成假空,两路必须不一致)、断语与结构化字段同源且 sentence 不混进字段,以及 MCP 把化爻落成字段。撤掉的「绝」标也立了断言:纳支落不上自己五行的绝地,免得日后有人当成漏做再加回来。 +书上两个乾为天卦例逐爻对上,以及六神不得改吉凶——同一个卦在二十八天里各起一次,初爻所临之神 +换过好几轮,吉凶须一字不动。化爻另核九项:回头生与回头克的两个原话方向、二十个五行有序组合各归五类 +且每类五个、《卜筮正宗》回头克五例与生克表对撞、另三类不带吉凶调子、十六对进退神歌诀逐字与同行与 +反向互证、只认本位动爻那条引文、变爻按静爻问(同一爻误记为发动就翻成假空,两路必须不一致)、 +断语与结构化字段同源且 sentence 不混进字段,以及 MCP 把化爻落成字段。撤掉的「绝」标也立了断言: +纳支落不上自己五行的绝地,免得日后有人当成漏做再加回来。元神忌神仇神这一圈另核八项:书上金用神的土元火忌木仇逐字对上、五行「余仿此」一组不差且三者永不撞行、仇神反生忌神而不生用神有断言、用神不上卦与两亲各看各的时这一圈不给、三元各报所在、动静(动、暗动、静、动而逢日冲四档)与旺衰、「勿以仇神即仇人也」守住、回头克四路各说一句(用神则凶、忌神仇神不作凶论、元神不许替它定、没有那一圈时不许硬接)、以及 MCP 把这一圈落成字段。暗动、日破与冲散另核十五项:章中坤之师卦例逐步复现并钉死作日破,使得定义与卦例不会各自漂走而不被人察觉;被冲的爻一动就从静爻那两路里退出;六十四卦配十二月建十二日辰与动爻各种组合扫过,确认一爻绝不会落进两路、且一卦不会既有暗动又有日破;用神段把暗动单列一档,不并进动爻也不并进静爻;元神暗动断喜、忌神暗动断忌;仇神暗动与圈外暗动一律不判;用神定不下来时不挂吉凶;用神旺相时把那层前提不齐点出来;章中风水涣变坎为水那个卦例逐步复现(丑月丁酉、上爻卯木动而逢酉冲);旺相、元神即冲散、用神即冲散三例各自把冲散落准;冲散那句不许改口断凶;冲散与月建无关这一条钉成可能失败的样子(同一日辰下换十二个月建结果不变);用神段把「动而逢日冲」单列一档而不是并回动爻;卦体与 MCP 两头都带着。两条 MCP 抬头断言扫遍六十四卦而不是钉死一组数字——工具走真实时钟,钉死某一天那一组数字,哪天一换就静悄悄空跑了。旺衰另按《四时旺相章》八个非四季土的月份在模块加载时钉死,囚与死掉个个儿当场就抛。月破单逢亦作真空另核三项:单逢月破不再退回「暂看不出真假」、月破那一句不再劝人等逢冲、理由里的「逢月破」不与标记重说一遍。六冲与六合另核十二项:六冲卦十个、六合卦八个逐名钉死、配对位钉死为初四二五三六(按内外同位配就一个六冲卦都出不来)、配对表从纳甲现读、「一组冲则三组必冲」这条在六十四卦上逐个成立、一卦不会又冲又合、四路可报的路数各自落在真卦例上、零散爻与爻冲压在六冲卦之下且不单独开段、用神那半句在旺与在囚两种情形下各说一句而用神定不下来时不接、近病久病只引不选边、官讼那一半只在所问真是官讼是非时才接,三对要么全撞要么全不撞(六十四卦逐个核,不许只撞一对),「三对皆撞」那两条自校验是活的且数目不从表里推,卦体与 MCP 两头都带着。边上的三支弧另核七项:卦盘挂上页面之后才画(元素没挂上时量行高全是零,画出来是空图)、宽度取自两栏之间的空隙而不是从卦面上扣、弧最远鼓出去的距离仍装得进那道空隙、起笔在卦面右缘之外、淡字不上朱砂、不挡点选与悬停、每支弧挂得出标题写清是哪两支。八宫名单另核十项:八格按世次排开且卦名逐宫与传世卦序对得上(每格卦名与卦象还须是同一卦)、世次自一世到五世逐爻上移而变爻数一级一级加、游魂那格不含第四爻、归魂只变第五爻、八格的世应一律隔三位、六十四卦都能在名单里找到自己那一格且两处世应一致、游魂归魂恰好十六卦(这一格在本卦上会同时挂两个标记,客户端那条朱砂压过加粗的规则就是为它们备的)、整份名单连同每格变爻一律冻结(同一宫八个卦读的是同一份数组)、名单不另抄一张表而是与宫、世次同一套推导出来的那一份。页面上那一列另核八项:它真的插进页面而不是函数写死不用、八宫排在四卦推导前头、抬头写清宫与五行、图注把「游」「归」的由来说明白、小卦那组样式没被挂回 .derive 底下(挂回去八格就掉朱砂变爻)、推导图自己的箭头与行距仍留在 .derive 里、本卦那格的朱砂压在游归加粗后面且两条特异性相同、八格只标变过的爻不标世爻(左卦盘上已经朱砂框过一次)。爻之合另核九项:六合表十二支两两配对不重不漏且合是双向的、日月同支只算一路不数两遍、合起只收静爻而合绊只收动爻(两路互不相交)、合好要两爻皆动(一静一动不算,且不在初四二五三六上的相合对恰好二十卦,拿雷火丰初六卯戌那一对做一动一静的对照)、化扶要动爻化出之爻回头相合、逢合爻位就是四路合起来的那几处、断语四名各有一句且不由合断吉凶(原章「宜合吉,不宜合凶」「用若失陷无益」两句照录)、卦体给逢合的爻挂淡字「合」小标(小标凡是带朱砂的规则只许 po 与 tomb 两个类)、MCP 另给 combine 字段与【逢合】抬头。爻之刑另核六项:底本六条与命理那八条不许混排(卯刑午不作卯刑子、未辰相刑不拆成未刑丑与戌刑未,两套混起来会让「有几爻犯刑」失去意义)、刑有方向(卯刑午成立而午刑卯不成立)、自刑里辰一支在六十四卦上恒为 0(辰只装在内卦三爻,一卦只有一个下卦,最多一个辰,碰不出本支见本支;午酉亥三支都碰得出,且自刑成对只报一次)、书上那个卦例逐步复现(寅月申日风火家人变离卦,月建寅刑五爻巳、日辰申被五爻巳所刑,两路落在同一爻上)、断语不由刑断吉凶且把「用神休囚」与「又兼他爻犯之」两条前提逐条核出来、卦体挂淡字「刑」小标而 MCP 另给 punish 字段与【犯刑】抬头。加载时还有一组自校验:六十四卦配十二日辰配十二月支配六十四种动静,逐条核四路各自的成立条件,核的是每一路凭什么算数而不是有没有出。客户端这几项读的是源码,测试环境里没有 DOM。共 212 项通过。 +- 算法口径:日柱由儒略日推算,与传世万年历一致(2000 年 1 月 1 日为戊午日);月支取最近一个 + 「节」,节气日期为常年近似值,可能差一日;年支以立春为界,立春近似取 2 月 4 日。本包不含 + 农历年历,不换算农历日期,也未声称支持。 +- 时间起卦的月、日取公历,这是通行的现代简化(古法用农历),应用会在「起卦依据」中写明, + 不作隐藏。 +- 节律有测试兜底:同一时辰内起卦与跨时辰起卦各测一次,并核对「预告的下一时辰之卦」与真的 + 到了那个时辰起卦的结果一致。 +- 本轮把结论块在**真页面上**逐项读过一遍,七个问法各起一卦:事业、财运、疾病、房产、出行五类都拿到 + 各自的取象句(分别落在「这份前程」「这笔进项」「这桩病症」「这处房产或这纸契」「这趟行程或这件失物」), + 两句按设计不摆——一个是「明天的会议会顺利吗」,本就不在那九类里;另一个是「他对我还有没有真心」, + **感情类的词表接不住这句**,于是整句消失而不是拿别的话凑。后者是这一轮查出来的真缺口, + 补词与否要拿捏「公司对我是不是真心的」这类同形问法,所以留在这里等人定,没擅自扩表。 + 七个场景里吉凶跨过大凶、大吉、吉、平四档,提醒块在非平局时出现、平局时整块不出现,都与代码一致; + 结论块在 DOM 里确实排在两栏之前,右栏不再有第二份 summary。摇卦进度在可访问性树里是 + `role="group"` 名为「摇卦进度」、内含 `role="status"` 的「已摇 0 / 6 爻」,条宽与计数的 0–6 全档由断言钉住。 +- 这一轮的工具环境有个坑值得记下来:**内置 Browser 在这台机器上派发不了点击**——`click` 报 + `success / dispatched: true`,页面却毫无反应(分区标签不切、输入框仍是占位符),按 `ref` 点的每次都 + `STALE_ELEMENT_REF`。页面脚本本身是活的(把 `document.title` 改掉立刻生效),后台标签页的定时器被 + 节流也是真的(`setInterval` 几十秒不回调)。绕法是让页面自己去点:同一服务上再起一层同源代理, + 把驱动脚本注进真页面,点击与等待改用「延迟图片的 load 事件」当定时器(事件不受节流影响), + 结果写进页面里一个 `<pre>`,Browser 只负责读。这条路走通了「浏览器里点一次能出结果页」—— + 上一轮卡在这里没验成。同一标签页连起第二卦时 `POST /cast` 发出去了没回来,直打真实服务与代理都连起 + 多卦正常、毫秒级返回,所以卡点在客户端动画的定时器被节流,不是服务端或代理。 +- 本轮新增的断言都做了变异验证:结论块的位置与七样东西的次序、宜忌与提醒各只渲染一处、右栏无重复、 + 事实表无箭头、短词只取冒号后那半,九类取象句各有一句且不重复、认不出就不摆、强弱跟着总分走, + 以及进度条 0–6 全档的条宽与计数——逐个把实现改坏,确认对应断言真的报红,17 条全部钉住, + 脚本跑完还原后 214 项全过。 +- 这一轮把两件事从「静态分析」挪到了「让模型判断」:MCP 的 `divination_cast` 多了一个 `topic` + 枚举参数,由 Agent 读完用户原话后自己挑事类;页面上仍然靠关键词,因为页面上没有能替你读问题的人。 + 两条路都验过:同一句「他对我还有没有真心」,关键词表接不住(`detectTopic` 返回 null,取象句整块 + 不摆),Agent 报 `love` 之后事类、取象句、用神、应期全部落得上去;而「这工作该不该跳」这种关键词 + 本来能撞出事业功名的,显式报 `wealth` 时听显式的。给了不在九类里的键当场报错(`INVALID_ARGUMENTS` + 带一句「拿不准就省略」),不当没给处理——静悄悄忽略会让 Agent 以为定过了。吉凶在五种 topic 下 + 逐项核过:label、score、体用生克三样一个字不动。 +- 白话块是把上面那套术语原样翻成人话,不是另起一套判断。体卦翻成「你」、用卦翻成「那件事」、 + 旺相休囚死翻成「你此刻有多少劲」——这三处是卦理里最抽象的地方。整块不许出现「体卦/用卦/ + 旺衰/类神/月令」五个词,测试里逐个查;**写这条断言时它先抓出了我自己文案里漏进来的 + 「月令」与「旺衰」,改了两轮才干净**。生克那层与总分不一致时(体克用本是生克小吉,体卦逢死地 + 总分落回「平」),差价就在白话里讲开,不留给用户去怀疑算错了。样例响应是从代码里真跑出来贴进去的, + 不是手写的。 +- 本轮新增断言都做了变异验证,26 条全部钉住,脚本跑完还原后 217 项全过。第一跑 8 条没钉住, + 逐条看下来六条是**测试自己写得不够狠**:旺衰那条只查了「术语没漏」没查「翻没翻」(把旺衰原样 + 塞回去照样全绿),空行那条查错了函数,`indexOf` 顺序那条在对方那行被删掉时 `-1` 仍然大于 + `-1`,还有两处直接读 `.topic.key` 在 topic 为 null 时抛 TypeError——TypeError 不带消息, + 按关键词判「钉住没有」永远对不上,改走可选链才报在自己的消息上。另两条是变异本身的问题: + 一条把 JS 改出了语法错误,报的是 SyntaxError 而非任何一条断言;一条改的是主文本块的 filter, + 与它声称的 plainBlock 无关。 +- **铜钱这一路在 MCP 上是全断的,由用户报出来的。** `divination_cast` 用 `method: coins` 必然报 + 「六次掷钱结果必须是 6 到 9 之间的整数」:`tossCoins()` 掷一次返回一个 `{ sum, coins }` 对象, + 而 `castByCoins()` 要的是自初爻起的六次结果组成的数组,对象递进去 `.length` 是 `undefined`, + 一条都过不去。页面那条路(`server.mjs`)是分开收六次再组卦,所以只有 MCP 这一处坏——用户绕开 + MCP 直接调引擎摇了一卦才发现。修法是一行。同时发现 `castByCoins` 的校验拦不住 `NaN` 与 + `undefined`(非数跟任何数比较都是 false),一次手搓的请求会安静组出一个全 0 的坤卦,看着像卦、 + 其实什么都没掷,一并补上 `Number.isInteger`。 +- 为什么前几轮没发现:**引擎那一层的 `castByCoins` 有几十条测试,全是直接喂数组**,而 MCP 端到端 + 只打过 `numbers` 一种起法,`time`/`daily`/`coins` 一次都没走过。四种起法于是整条没有端到端覆盖。 + 现在四个方法一起打:卦名与卦序在 1–64、吉凶在五档、正文末尾有白话段、**四条起法都得把 Agent + 报的事类往下传**(少传一条照样起得出完整卦,从外面看不出来);铜钱另外核「起卦依据」那行真列了 + 六个点数,连摇十二次不只摇得出一个卦。 +- 「时间起卦用当下时辰」也补了一条凭据:拿起卦依据里那行「月 · 日」去比对今天。 + **一开始比对的是月令旺衰,那条路验不出来**——月令是从 `buildReading` 的 `now` 算的, + 把 `castByTime` 的参数换成 1970 年它照样是今月的金,两头都对。跨零点那一秒按前后各取一次日期兜住。 +- SKILL 里补了「起卦前先把这件事问清楚」:见问就起卦,得到的解读挂不到用户真实的处境上, + 抽象且苍白。这一段连同九个事类的键与「照着末了那段白话讲」一起立了断言——那是 Agent 的入口, + 仓库自检只查文件在不在,一次「顺手精简」就能把它们悄悄弄没。 +- 本轮 14 条变异全部钉住,脚本跑完还原后 219 项全过。第一跑有 5 条没钉住,两条是真缺口 + (四法都没断言把 topic 传下去、「用当下」没有凭据),两条是我押错了关键词,还有一条**根本是 + 等价变异**:`Array.isArray` 在长度检查面前是冗余的,去掉它没有任何行为变化,那一刀要换。 + +## 数据与访问 + +- 读取文件:仅本包内的 `miniapp/client/index.html` 与 Node 载荷目录。不读取任何 Host 文件、 + 用户文件或包目录之外的路径。 +- 写入文件:`context.dataDir` 下的 `readings.json`。该目录由 Host 创建并归属本 Mini App, + 内含保存的卦象与批注,按时间倒序,最多 500 条。写入先落到同目录临时文件再改名, + 中途失败不会留下半截文件。改名在目标被别的进程短暂占住时退避重试——Windows 上杀毒扫描 + 或搜索索引打开文件时就会这样。无法解析的 `readings.json` 会被改名另存为 + `readings.json.corrupt-<时间戳>` 而不是删掉,里面手写的批注捞得回来,卦历则按空表继续走。 + 除该文件外不写任何位置。 +- 网络:**无出站连接。** Node 进程不发起任何对外连接,页面不加载任何远程资源、字体或脚本,也不 + 调用任何模型 API。MCP 端点只监听 Host 指定的回环地址 `context.listen`,只接受 POST。 +- 子进程:不启动任何子进程。摇卦使用 Node 进程内的 `node:crypto.randomInt`。 +- 凭证:不读取、不持有任何凭证,未申请 Host Connector 访问。 +- 只有 `Host` 头指向回环、且带了 `Origin` 头时它也指向回环的请求才被受理。两个头各堵一条路: + 把自有域名解析到 `127.0.0.1` 的站点(DNS rebinding)由 `Host` 挡下,那时它带的是攻击者的域名; + 直接朝端口打过来的跨源请求由 `Origin` 挡下,那时 `Host` 反而是真的。缺头不当成伪造——Host 的 + MCP 客户端是 Node 程序,不发 `Origin`。被拒的请求回 `403`,且不回显任何东西。 +- 日志只记错误码,不记错误文本。Node 的 fs 报错会把含操作系统用户名的完整绝对路径写进 + `error.message`;dataDir 按契约是不透明的,那串路径不离开这个进程——日志会被贴进 issue、被上传。 + +## 文件 + +```text +.minimax-plugin/plugin.json 插件清单 +package.json Mini App 声明 +servers.mcp.json MCP 端点声明 +skills/divination/SKILL.md 供 Agent 读取的起卦与解读规程 +miniapp/miniapp.json 载荷根、Node 入口、页面路由、MCP 端点 +miniapp/client/index.html /divination 上的页面 +miniapp/node/server.mjs Node 入口:路由、MCP 挂载与 start(context) → { dispose } +miniapp/node/hexagrams.mjs 八卦与六十四卦 +miniapp/node/yao.mjs 三百八十四条爻辞,逐条与卦象校验过 +miniapp/node/xiang-chuan.mjs 三百八十四条小象传,两版对校后逐条落定 +miniapp/node/tuan.mjs 六十四卦彖传,两版对校后逐条落定 +miniapp/node/guaqi.mjs 十二辟卦配十二月,卦气取用 +miniapp/node/jingfang.mjs 京房八宫、纳支歌诀、六亲、世应 +miniapp/node/xiang.mjs 爻位之象与应期 +miniapp/node/topics.mjs 五行类事:所问何事 → 事类 → 类神 +miniapp/node/almanac.mjs 干支、十二神、节气、生肖 +miniapp/node/divination.mjs 梅花易数起卦与解卦 +miniapp/node/store.mjs dataDir 卦历持久化 +miniapp/node/mcp/divination-http.mjs MCP 端点协议层与三个工具 +miniapp/node/miniapp-api.ts 运行时上下文的类型声明 +tests/divination.test.mjs 单元测试,不进运行时载荷 +icon.png 插件图标 +``` + +## 使用提醒 + +**本应用文字由 AI 生成,仅供娱乐,无实际预测功能。** 页面底部每一页都写明了这句话。 + +本应用忠实地实现一种传统占筮方法,它是文化与哲思工具,不是预测服务。请把卦象当作梳理自己 +已有疑问的提示,而非据以行事的预言。医疗、法律、财务等重要决定,请以专业意见为准。 + +## 许可 + +[MIT](./LICENSE) diff --git a/plugins/weekbin/chinese-divination/icon.png b/plugins/weekbin/chinese-divination/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..383616c071237b65731e72776c262bd6c76225a6 GIT binary patch literal 143528 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�Sp17<I?nhQvl-#It=L zWDCc;oMbi})crjoIsXGxq4{%k5V(`VFaTheT?G$c^sj4N!RhZq@d-}?028n(MtH2p zO_TTw$}NVs$1gJjk@ae+N9yF_(!q=`F$?CKH~FsNg8ih|r89%sC7&}mEy!SS?ejYG zA+DYg1BOrL74}$Mm+u@Xm-1)94IYFu3dpfMm&jiHB(cIWkTZOmSJI<dJGYO{-_*2- z+%~Ls2S04e`wVeIsq$nugI##CE~?uA^RyGEz}gh~H`2<z77u3tem4k_&oRs};ao7o z3%4Go5o@}No?LH^$(IR^&oYc<i#9L(FJIU}X#482b*ZbG^L2f67YLGaI^CG~t&i=- z1g~ouykw94>EsN*c&!|XYLhmV2Ox!qL9}DrEm9erB6=X~MO<LPWjGO;Er23lG0gaa zz!KE!XjtXt=DlS|zQ9SvI`Bq=3&@STnN0mMZG(s`d$m%sH~Dz8J%79fZHP(`jV@kb zdpS^?8{+3&bl7+=(uVuMrd$Y<R}-7nS?sInHZ+&V57O)WvF!y*3j=P@r~e`L)kw$f zXfvZ)f>GnIHVzpd!3L;=!cY;^G00L;{twf{1G&T1BX!36s_CikEF8f}Yvub7g506i ze?kup*O+{iAFZxjqQorV?YNMu*yIPua}zLEt9?n%0DmpZXRr@xs?T$}Ph2u$#i;A! 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πAmj;we=U?45b>EO!28 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 VG5~}$fdc>l002ovPDHLkV1h4L=r;fW literal 0 HcmV?d00001 diff --git a/plugins/weekbin/chinese-divination/miniapp/client/index.html b/plugins/weekbin/chinese-divination/miniapp/client/index.html new file mode 100644 index 0000000..0c21567 --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/client/index.html @@ -0,0 +1,3622 @@ +<!doctype html> +<html lang="zh-CN"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>灵签易占 + + + +
+
+
+

灵签易占

+

梅花易数起卦 · 六十四卦参详 · 干支历法速查

+
+
+ + +
+ +
+
+
+

所问何事

+

+ 问事只定事类与应期,不改卦体本身的吉凶:认出财运取金、事业取火、婚恋取木、疾病取土…… + 认不出就按用卦算。写下问题,铜钱起卦与时间起卦才解得不一样。 +

+ +
+ +
+

择一法起卦

+

四法皆本梅花易数,只是取数来源不同。

+
+ + + + +
+

+ 四法的取数来源不同,变卦的快慢也不同:铜钱摇卦每掷一次即一卦,数字起卦换一组数即变; + 时间起卦两小时换一次,每日一卦一天一换。同一时辰、同一天内反复起卦,自然是同一个卦。 +

+ + +
+ + + +
+
+ + + + + + +
+ +
+ 内容由 AI 生成,仅供娱乐,无实际预测功能。 +

+ 本应用实现的是中国传统占筮方法,依据梅花易数的体用生克、旺衰与取象规则推演, + 所有文字由 AI 生成。卦象不构成任何建议、预测或决策依据,不应作为医疗、法律、财务 + 等重要决定的参考。娱乐之外,请以自身判断与专业意见为准。 +

+
+
+ +
+ + + + diff --git a/plugins/weekbin/chinese-divination/miniapp/miniapp.json b/plugins/weekbin/chinese-divination/miniapp/miniapp.json new file mode 100644 index 0000000..aa0925d --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/miniapp.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": ["./miniapp/client"], + "node": ["./miniapp/node"] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/divination" + }, + "mcpEndpoints": [ + { + "server": "divination", + "path": "/mcp/divination" + } + ] +} diff --git a/plugins/weekbin/chinese-divination/miniapp/node/almanac.mjs b/plugins/weekbin/chinese-divination/miniapp/node/almanac.mjs new file mode 100644 index 0000000..53bc9d0 --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/node/almanac.mjs @@ -0,0 +1,453 @@ +// @ts-check + +/** + * 干支历法与时辰吉凶。 + * + * 干支纪日用儒略日公式推算,与传世万年历一致;月支以二十四节为准(节气日期为常年近似值, + * 可能有一日之差),不引入农历年历表,因此本模块不声称能换算农历初一。 + */ + +export const STEMS = Object.freeze(['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸']); +export const BRANCHES = Object.freeze(['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥']); +export const ZODIAC = Object.freeze(['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪']); + +export const ELEMENTS = Object.freeze(['木', '火', '土', '金', '水']); + +/** 天干五行。 */ +export const STEM_ELEMENTS = Object.freeze(['木', '木', '火', '火', '土', '土', '金', '金', '水', '水']); +/** 地支五行。 */ +export const BRANCH_ELEMENTS = Object.freeze(['水', '土', '木', '木', '土', '火', '火', '土', '金', '金', '土', '水']); + +/** 地支三合局。 */ +export const TRIPLE_HARMONY = Object.freeze([ + { branches: [8, 0, 4], element: '水', name: '申子辰' }, + { branches: [11, 3, 7], element: '木', name: '亥卯未' }, + { branches: [2, 6, 10], element: '火', name: '寅午戌' }, + { branches: [5, 9, 1], element: '金', name: '巳酉丑' }, +]); + +/** 地支六合。 */ +export const SIX_HARMONY = Object.freeze([ + [0, 1], [2, 11], [3, 10], [4, 9], [5, 8], [6, 7], +]); + +/** + * 六冲:子午、丑未、寅申、卯酉、辰戌、巳亥。 + * 十二支排成圈便隔六位相冲,不必另立表,索引加六取模十二即是。 + * @param {number} branch 地支索引 0–11 + * @returns {number} 与之相冲的地支索引 + */ +export function branchClash(branch) { + return (branch + 6) % 12; +} + +/** + * 旬空歌诀,《增删卜易·旬空章第二十六》原文,六句。 + * 存成数据是为了让测试能逐字对着底本校,不是拿它去查——xunKong 是按序号算的, + * 歌诀只是它的对校;两者对不上就是其中之一错了。 + */ +export const XUNKONG_SONG = Object.freeze([ + '甲子旬中戌亥空', '甲戌旬中申酉空', '甲申旬中午未空', + '甲午旬中辰巳空', '甲辰旬中寅卯空', '甲寅旬中子丑空', +]); + +/** + * 旬空(空亡)。《增删卜易·旬空章第二十六》: + * 「甲子旬中戌亥空,甲戌旬中申酉空,甲申旬中午未空, + * 甲午旬中辰巳空,甲辰旬中寅卯空,甲寅旬中子丑空。」 + * 天干十位配十二支,一旬十日总有两支配不上天干,那两支便是本旬的空。 + * 六十甲子以旬首的地支为头,缺的两支正在头的**前两位**——这是本函数算法, + * 上面六句歌诀是它的对校,不是它的来源。 + * @param {number} dayIndex 日柱在六十甲子中的序 0–59 + * @returns {{xun: number, head: number, headName: string, voidBranches: number[], voidNames: string[]}} + */ +export function xunKong(dayIndex) { + const xun = Math.floor(dayIndex / 10); + const head = (xun * 10) % 12; + const voidBranches = [(head + 10) % 12, (head + 11) % 12]; + return { + xun, + head, + headName: BRANCHES[head], + voidBranches, + voidNames: voidBranches.map((index) => BRANCHES[index]), + }; +} + +/** + * 月破:月建所冲之支。《增删卜易》定「月破者,月建冲爻之谓」, + * 十二个月逐月各破一支——正月申破、二月酉破……十二月未破,与六冲同源。 + * @param {number} monthBranch 月支索引 0–11 + * @returns {number} 该月所破的地支索引 + */ +export function monthPo(monthBranch) { + return branchClash(monthBranch); +} + +/** + * 五行入墓。《纳甲筮法讲义·生旺墓绝》: + * 金长生在巳,旺在酉,墓在丑。 + * 木长生在亥,旺在卯,墓在未。 + * 水土长生在申,旺在子,墓在辰。 + * 火长生在寅,旺在午,墓在戌。 + * 墓取**自墓**一支(丑未辰戌各归一行)。另有「库」法以辰为水库、戌为火库、 + * 丑为金库、未为木库,与此不同;本包只用上表,断语遇到入墓会写明依的是哪一支。 + * + * 同一张表里原本还记了「绝」(墓的下一支:金绝寅、木绝申、水土绝巳、火绝亥)。它已删, + * 不是漏做:纳甲里每个五行只占两支(金申酉、木寅卯、水子亥、火巳午、土丑辰未戌), + * 上列的绝支**没有一支落在该五行自己占的那两支里**,所以任何一爻都逢不上绝地。 + * 这一点由装卦那一层在模块加载时逐支核过并抛错,核的是「绝地不出现」这个事实, + * 不是「绝地清单」——将来谁动了纳支或这张表,这里先炸。 + * + * @param {string} element 五行 + * @returns {{mu: number}} + */ +const MU_JUE = Object.freeze({ + 金: Object.freeze({ mu: 1 }), // 墓丑 + 木: Object.freeze({ mu: 7 }), // 墓未 + 水: Object.freeze({ mu: 4 }), // 墓辰 + 土: Object.freeze({ mu: 4 }), // 墓辰 + 火: Object.freeze({ mu: 10 }), // 墓戌 +}); + +export function muJue(element) { + return MU_JUE[element]; +} + +/** + * 四季之真空。《增删卜易·旬空章》引《黄金策》口诀:「春土、夏金、秋木、三冬逢火是真空。」 + * 四季以月支三分:寅卯辰春、巳午未夏、申酉戌秋、亥子丑冬。 + * @param {number} monthBranch 月支索引 0–11 + * @returns {{season: string, vacuousElement: string}} + */ +const SEASON_VACUOUS = Object.freeze([ + Object.freeze({ season: '冬', vacuousElement: '火' }), // 亥 + Object.freeze({ season: '冬', vacuousElement: '火' }), // 子 + Object.freeze({ season: '春', vacuousElement: '土' }), // 寅 + Object.freeze({ season: '春', vacuousElement: '土' }), // 卯 + Object.freeze({ season: '春', vacuousElement: '土' }), // 辰 + Object.freeze({ season: '夏', vacuousElement: '金' }), // 巳 + Object.freeze({ season: '夏', vacuousElement: '金' }), // 午 + Object.freeze({ season: '夏', vacuousElement: '金' }), // 未 + Object.freeze({ season: '秋', vacuousElement: '木' }), // 申 + Object.freeze({ season: '秋', vacuousElement: '木' }), // 酉 + Object.freeze({ season: '秋', vacuousElement: '木' }), // 戌 + Object.freeze({ season: '冬', vacuousElement: '火' }), // 亥 +]); + +export function seasonVacuous(monthBranch) { + return SEASON_VACUOUS[monthBranch]; +} + +/** 十二时辰,索引即地支序。 */ +export const SHICHEN = Object.freeze( + BRANCHES.map((name, index) => { + const startHour = (index * 2 + 23) % 24; + const endHour = (startHour + 2) % 24; + return Object.freeze({ + index, + name: `${name}时`, + branch: name, + startHour, + endHour, + range: `${pad(startHour)}:00 - ${pad(endHour)}:00`, + }); + }), +); + +/** 黄黑道十二神,循环一周。 */ +const TWELVE_OFFICE = Object.freeze([ + { name: '青龙', auspicious: true }, + { name: '明堂', auspicious: true }, + { name: '天刑', auspicious: false }, + { name: '朱雀', auspicious: false }, + { name: '金匮', auspicious: true }, + { name: '天德', auspicious: true }, + { name: '白虎', auspicious: false }, + { name: '玉堂', auspicious: true }, + { name: '天牢', auspicious: false }, + { name: '玄武', auspicious: false }, + { name: '司命', auspicious: true }, + { name: '勾陈', auspicious: false }, +]); + +/** 建除十二神。 */ +export const JIANCHU = Object.freeze([ + { name: '建', suitable: '出行、上任、祈福', avoid: '动土、开仓' }, + { name: '除', suitable: '扫舍、治病、解除', avoid: '出行、求财' }, + { name: '满', suitable: '祭祀、祈福、开市', avoid: '服药' }, + { name: '平', suitable: '修饰、整平道路', avoid: '求医' }, + { name: '定', suitable: '冠笄、安床、嫁娶', avoid: '诉讼' }, + { name: '执', suitable: '造屋、收购、捕捉', avoid: '开市' }, + { name: '破', suitable: '破屋坏垣、求医', avoid: '嫁娶、开市' }, + { name: '危', suitable: '安床、祭祀', avoid: '登高、行船' }, + { name: '成', suitable: '开市、嫁娶、入学', avoid: '诉讼' }, + { name: '收', suitable: '纳财、进人口', avoid: '开仓' }, + { name: '开', suitable: '开市、入学、动土', avoid: '安葬' }, + { name: '闭', suitable: '筑堤、埋穴、安葬', avoid: '开市、出行' }, +]); + +/** + * 二十四节气。`month`/`day` 是该节气在公历中的常年近似日期,交节时刻逐年摆动, + * 本模块按日期比较,误差至多一日。 + * @type {readonly {name: string, month: number, day: number, kind: '节'|'中气', branch?: number, solar: string}[]} + */ +export const SOLAR_TERMS = Object.freeze([ + { name: '立春', month: 2, day: 4, kind: '节', branch: 2, solar: '东风解冻,蛰虫始振,鱼陟负冰' }, + { name: '雨水', month: 2, day: 19, kind: '中气', solar: '獭祭鱼,鸿雁来,草木萌动' }, + { name: '惊蛰', month: 3, day: 6, kind: '节', branch: 3, solar: '桃始华,仓庚鸣,鹰化为鸠' }, + { name: '春分', month: 3, day: 21, kind: '中气', solar: '玄鸟至,雷乃发声,始电' }, + { name: '清明', month: 4, day: 5, kind: '节', branch: 4, solar: '桐始华,田鼠化为鴽,虹始见' }, + { name: '谷雨', month: 4, day: 20, kind: '中气', solar: '萍始生,鸣鸠拂其羽,戴胜降于桑' }, + { name: '立夏', month: 5, day: 6, kind: '节', branch: 5, solar: '蝼蝈鸣,蚯蚓出,王瓜生' }, + { name: '小满', month: 5, day: 21, kind: '中气', solar: '苦菜秀,靡草死,麦秋至' }, + { name: '芒种', month: 6, day: 6, kind: '节', branch: 6, solar: '螳螂生,鵙始鸣,反舌无声' }, + { name: '夏至', month: 6, day: 21, kind: '中气', solar: '鹿角解,蜩始鸣,半夏生' }, + { name: '小暑', month: 7, day: 7, kind: '节', branch: 7, solar: '温风至,蟋蟀居壁,鹰始挚' }, + { name: '大暑', month: 7, day: 23, kind: '中气', solar: '腐草为萤,土润溽暑,大雨时行' }, + { name: '立秋', month: 8, day: 8, kind: '节', branch: 8, solar: '凉风至,白露降,寒蝉鸣' }, + { name: '处暑', month: 8, day: 23, kind: '中气', solar: '鹰乃祭鸟,天地始肃,禾乃登' }, + { name: '白露', month: 9, day: 8, kind: '节', branch: 9, solar: '鸿雁来,雀入大水为蛤,菊有黄华' }, + { name: '秋分', month: 9, day: 23, kind: '中气', solar: '雷始收声,蛰虫坯户,水始涸' }, + { name: '寒露', month: 10, day: 8, kind: '节', branch: 10, solar: '鸿雁来宾,雀入大水为蛤,菊有黄华' }, + { name: '霜降', month: 10, day: 23, kind: '中气', solar: '豺乃祭兽,草木黄落,蛰虫咸俯' }, + { name: '立冬', month: 11, day: 7, kind: '节', branch: 11, solar: '水始冰,地始冻,雉入大水为蜃' }, + { name: '小雪', month: 11, day: 22, kind: '中气', solar: '虹藏不见,天气上升地气下降' }, + { name: '大雪', month: 12, day: 7, kind: '节', branch: 0, solar: '鹖鴠不鸣,虎始交,荔挺出' }, + { name: '冬至', month: 12, day: 22, kind: '中气', solar: '蚯蚓结,麋角解,水泉动' }, + { name: '小寒', month: 1, day: 6, kind: '节', branch: 1, solar: '雁北乡,鹊始巢,雉始雊' }, + { name: '大寒', month: 1, day: 20, kind: '中气', solar: '鸡始乳,征鸟厉疾,水泽腹坚' }, +].map((term) => Object.freeze(term))); + +const JIE_TERMS = SOLAR_TERMS.filter((term) => term.kind === '节'); + +/** + * 公历日期的儒略日数。 + * @param {number} year + * @param {number} month 1-12 + * @param {number} day + * @returns {number} + */ +export function julianDayNumber(year, month, day) { + const a = Math.floor((14 - month) / 12); + const y = year + 4800 - a; + const m = month + 12 * a - 3; + return day + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) - 32045; +} + +/** + * 干支纪日:0 为甲子。公历 2000-01-01 为戊午日,与传世万年历一致。 + * @param {number} year + * @param {number} month + * @param {number} day + * @returns {{ index: number, stem: number, branch: number, name: string }} + */ +export function dayPillar(year, month, day) { + const index = ((julianDayNumber(year, month, day) + 49) % 60 + 60) % 60; + const stem = index % 10; + const branch = index % 12; + return { index, stem, branch, name: `${STEMS[stem]}${BRANCHES[branch]}` }; +} + +/** + * 年干支。干支历年以立春为界,本模块按立春近似日(2 月 4 日)切换。 + * @param {number} year + * @param {number} month + * @param {number} day + */ +export function yearPillar(year, month, day) { + const effectiveYear = month < 2 || (month === 2 && day < 4) ? year - 1 : year; + const offset = (effectiveYear - 4) % 60; + const stem = ((offset % 10) + 10) % 10; + const branch = ((offset % 12) + 12) % 12; + return { + year: effectiveYear, + index: offset, + stem, + branch, + name: `${STEMS[stem]}${BRANCHES[branch]}`, + element: STEM_ELEMENTS[stem], + zodiac: ZODIAC[branch], + }; +} + +/** + * 当前所处的节,节气月支由它决定。 + * @param {number} year + * @param {number} month + * @param {number} day + * @returns {{ term: typeof JIE_TERMS[number], branch: number }} + */ +export function currentJie(year, month, day) { + const current = month * 100 + day; + let bestTerm = null; + let bestOrder = -1; + for (const term of JIE_TERMS) { + const order = term.month * 100 + term.day; + if (order <= current && order > bestOrder) { + bestTerm = term; + bestOrder = order; + } + } + // 全年最早的一节是小寒;在此之前仍处上一轮大雪之后的子月。 + const term = bestTerm ?? JIE_TERMS.find((item) => item.name === '大雪'); + if (!term || term.branch === undefined) throw new Error('solar term table is incomplete'); + return { term, branch: term.branch }; +} + +/** 六十甲子序:同干同支唯一。 */ +function cycleIndex(stem, branch) { + for (let i = 0; i < 60; i += 1) { + if (i % 10 === stem && i % 12 === branch) return i; + } + return -1; +} + +/** + * 月干支。五虎遁:年干起寅月。 + */ +export function monthPillar(year, month, day) { + const jie = currentJie(year, month, day); + const yearStem = yearPillar(year, month, day).stem; + const yinStem = ((yearStem % 5) * 2 + 2) % 10; + const stem = (yinStem + (jie.branch - 2 + 12) % 12) % 10; + return { + index: cycleIndex(stem, jie.branch), + stem, + branch: jie.branch, + name: `${STEMS[stem]}${BRANCHES[jie.branch]}`, + jie: jie.term.name, + element: BRANCH_ELEMENTS[jie.branch], + }; +} + +/** + * 时干支,直接按地支序求。五鼠遁:日干起子时。 + * @param {number} dayStem + * @param {number} branch 地支序 0 至 11 + */ +export function hourPillarByBranch(dayStem, branch) { + const stem = ((dayStem % 5) * 2 + branch) % 10; + return { index: cycleIndex(stem, branch), stem, branch, name: `${STEMS[stem]}${BRANCHES[branch]}` }; +} + +/** + * 时干支,由钟点求地支后再算。子时跨 23:00 与 00:00。 + * @param {number} dayStem + * @param {number} hour 0-23 + */ +export function hourPillar(dayStem, hour) { + return hourPillarByBranch(dayStem, Math.floor(((hour + 1) % 24) / 2)); +} + +/** + * 黄黑道十二神在某日某时落位。青龙起点按日支三合局:申子辰起子、寅午戌起寅、巳酉丑起巳、亥卯未起亥。 + * @param {number} dayBranch + * @param {number} hourBranch + */ +export function hourOffice(dayBranch, hourBranch) { + const trigramGroup = [ + [8, 0, 4], [2, 6, 10], [5, 9, 1], [11, 3, 7], + ].findIndex((group) => group.includes(dayBranch)); + const qinglongPosition = [0, 2, 5, 11][trigramGroup] ?? 0; + // 十二神随时辰顺行:青龙所在时辰之后依次为明堂、天刑、朱雀…… + const offset = (hourBranch - qinglongPosition + 12) % 12; + const office = TWELVE_OFFICE[offset]; + return { name: office.name, auspicious: office.auspicious, type: office.auspicious ? '黄道' : '黑道' }; +} + +/** + * 建除十二神:日支与月支同者建,之后每日顺行一位。 + */ +export function jianchu(dayBranch, monthBranch) { + return JIANCHU[(((dayBranch - monthBranch) % 12) + 12) % 12]; +} + +/** + * 数九。冬至起头九,九九八十一天。 + */ +export function shujiu(year, month, day) { + const dongzhi = SOLAR_TERMS.find((term) => term.name === '冬至'); + if (!dongzhi) return null; + const current = julianDayNumber(year, month, day); + // 冬至在 12 月 22 日前后,跨年时取上一年冬至起算。 + const startYear = month * 100 + day >= 1222 ? year : year - 1; + const start = julianDayNumber(startYear, dongzhi.month, dongzhi.day); + const elapsed = current - start; + if (elapsed < 0 || elapsed > 80) return null; + const round = Math.floor(elapsed / 9) + 1; + const numerals = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九']; + const label = `${numerals[round]}九`; + const stage = elapsed <= 8 ? '入九' : elapsed >= 72 ? '出九' : '数九中'; + return { round, label, stage, elapsed, total: 81 }; +} + +/** + * 今日完整历法快照。 + * @param {Date} date + */ +export function almanac(date) { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hour = date.getHours(); + const dayGanZhi = dayPillar(year, month, day); + const yearGanZhi = yearPillar(year, month, day); + const monthGanZhi = monthPillar(year, month, day); + const currentHour = hourPillar(dayGanZhi.stem, hour); + const jian = jianchu(dayGanZhi.branch, monthGanZhi.branch); + + const hours = SHICHEN.map((shichen) => { + const pillar = hourPillarByBranch(dayGanZhi.stem, shichen.index); + const office = hourOffice(dayGanZhi.branch, shichen.index); + return { + index: shichen.index, + branch: shichen.branch, + name: shichen.name, + range: shichen.range, + pillar: pillar.name, + pillarBranch: BRANCHES[pillar.branch], + office: office.name, + officeType: office.type, + auspicious: office.auspicious, + verdict: office.auspicious ? '吉时' : '凶时', + current: shichen.index === currentHour.branch, + }; + }); + + return { + date: `${year}-${pad(month)}-${pad(day)}`, + year: yearGanZhi, + month: monthGanZhi, + day: dayGanZhi, + hour: { ...currentHour, name: `${BRANCHES[currentHour.branch]}时`, range: SHICHEN[currentHour.branch].range }, + jianchu: jian, + shujiu: shujiu(year, month, day), + currentTerm: monthGanZhi.jie, + hours, + luckyHours: hours.filter((item) => item.auspicious).map((item) => item.name), + }; +} + +/** + * 十二生肖的生克关系,用于「今日生肖」速查。 + * @param {number} branch 地支序 + */ +export function zodiacProfile(branch) { + const harmony = SIX_HARMONY.find((pair) => pair.includes(branch)); + const partner = harmony ? harmony[0] === branch ? harmony[1] : harmony[0] : undefined; + const triple = TRIPLE_HARMONY.find((group) => group.branches.includes(branch)); + const conflict = (branch + 6) % 12; + return { + zodiac: ZODIAC[branch], + branch: BRANCHES[branch], + element: BRANCH_ELEMENTS[branch], + harmony: partner === undefined ? undefined : ZODIAC[partner], + triple: triple ? { name: triple.name, element: triple.element } : undefined, + conflict: ZODIAC[conflict], + }; +} + +function pad(value) { + return String(value).padStart(2, '0'); +} diff --git a/plugins/weekbin/chinese-divination/miniapp/node/divination.mjs b/plugins/weekbin/chinese-divination/miniapp/node/divination.mjs new file mode 100644 index 0000000..d429c90 --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/node/divination.mjs @@ -0,0 +1,1862 @@ +// @ts-check + +/** + * 梅花易数:起卦与解卦。 + * + * 体用生克是本模块的判吉凶依据;互卦看过程、变卦看结果、错卦看旁支、综卦看反求, + * 都由爻象推导,不查外部表。 + */ + +import { randomInt } from 'node:crypto'; + +import { + TRIGRAMS, + earlyKey, + hexagramByKey, + hexagramByNumbers, + hexagramSymbol, + invertedHexagram, + mutualHexagram, + normalizeToEight, + normalizeToSix, + oppositeHexagram, +} from './hexagrams.mjs'; +import { monthPillar, yearPillar, dayPillar, hourPillar, monthPo, xunKong, branchClash, BRANCHES, BRANCH_ELEMENTS } from './almanac.mjs'; +import { LINE_POSITIONS, responseTiming } from './xiang.mjs'; +import { lineText } from './yao.mjs'; +import { lineXiang } from './xiang-chuan.mjs'; +import { monthQi, hexagramQi } from './guaqi.mjs'; +import { jingfang, pickUseGod, hiddenGod, flyingRelation, shiYingRelation, elementRelation, + voidReading, vitality, sixGods, SIX_GOD_MEANING, RELATIVE_MEANING, transformRelation, jinTui, + useGodCircle, dayClashReading, heCombineReading, punishReading, hexagramClash, fanfuReading } from './jingfang.mjs'; +import { detectTopic, godRelation, topicByKey, TOPIC_CLASSES } from './topics.mjs'; + +const GENERATES = Object.freeze({ 木: '火', 火: '土', 土: '金', 金: '水', 水: '木' }); +const OVERCOMES = Object.freeze({ 木: '土', 土: '水', 水: '火', 火: '金', 金: '木' }); + +/** a 生 b。导出供测试与类神取象共用,避免五行规则在两处各写一份。 @param {string} a @param {string} b */ +export function generates(a, b) { + return GENERATES[a] === b; +} + +/** a 克 b。 @param {string} a @param {string} b */ +export function overcomes(a, b) { + return OVERCOMES[a] === b; +} + +/** + * @typedef {object} MovingLine + * @property {number} position 1 为初爻,6 为上爻 + * @property {string} label 初爻 / 二爻 ... + * @property {0|1} value 本卦爻值 + * @property {0|1} changed 变卦爻值 + * @property {number} sum 铜钱数 6-9,数字起卦与时间起卦为 null + * @property {'老阴'|'少阳'|'少阴'|'老阳'|'静爻'} kind + * @property {boolean} moving 是否动爻 + * @property {string|null} text 爻辞原文「爻题:爻辞」 + * @property {string|null} title 爻题,如「初九」 + * @property {string|null} xiang 小象传原文「爻题:象辞」 + */ + +/** + * @typedef {object} Reading + * @property {string} id + * @property {string} method + * @property {string} question + * @property {string} createdAt + * @property {object} hexagram 本卦 + * @property {object|null} changed 变卦 + * @property {object|null} mutual 互卦 + * @property {object|null} opposite 错卦 + * @property {object|null} inverted 综卦 + * @property {MovingLine[]} lines + * @property {MovingLine[]} movingLines + * @property {object} structure 体用、五行、世应 + * @property {object} verdict 吉凶断语 + * @property {{ key: string, label: string, element: string, reason: string }|null} topic 认出事类时的类神 + * @property {'explicit'|'detected'|null} topicSource 事类是谁定的:Agent 显式给的、关键词撞出来的,还是没定 + * @property {string} [topicLine] 取象句。认不出事类时这个键不存在 + * @property {{ ask: string, topic: string, verdict: string, why: string, onTopic: string, timing: string, actions: string, caution: string }} plain 白话结论块 + * @property {string} timing 应期 + * @property {{ title: string, text: string }[]} insights + * @property {{ label: string, value: string }[]} details 起卦依据 + * @property {{ suitable: string[], avoid: string[] }} advice + */ + +const POSITION_LABELS = Object.freeze(['初爻', '二爻', '三爻', '四爻', '五爻', '上爻']); +const BRANCH_NAMES = Object.freeze(['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥']); + +function pad2(value) { + return String(value).padStart(2, '0'); +} + +/** 对应爻:初应四、二应五、三应上。 */ +function counterpart(position) { + if (position <= 3) return position + 3; + return position - 3; +} + +/** + * 由卦象与动爻位生成完整的六爻信息。 + * @param {string} key 六位爻象串 + * @param {number[]} positions 动爻位,1 至 6 + * @param {Array} sums 每爻对应的铜钱数 + * @param {number} [order] 卦序 1 至 64,缺省则不带爻辞 + * @returns {(MovingLine & { text: string|null, title: string|null, xiang: string|null })[]} + */ +function buildLines(key, positions, sums, order = 0) { + const moving = new Set(positions); + return [...key].map((line, index) => { + const position = index + 1; + const value = /** @type {0|1} */ (line === '1' ? 1 : 0); + const sum = sums[index] ?? null; + const isMoving = moving.has(position); + const changed = isMoving ? (value === 1 ? 0 : 1) : value; + let kind = '静爻'; + if (isMoving) kind = value === 1 ? '老阳' : '老阴'; + else if (sum === 7) kind = '少阳'; + else if (sum === 8) kind = '少阴'; + const text = order ? lineText(order, position) : null; + return { + position, + label: POSITION_LABELS[index], + value, + changed: /** @type {0|1} */ (changed), + sum, + kind, + moving: isMoving, + text, + title: text ? text.slice(0, text.indexOf(':')) : null, + xiang: order ? lineXiang(order, position) : null, + }; + }); +} + +/** 去掉「爻题:」前缀,只留正文。 */ +function afterColon(entry) { + if (!entry) return ''; + const at = entry.indexOf(':'); + return at < 0 ? entry : entry.slice(at + 1); +} + +function changedKey(lines) { + return lines.map((line) => String(line.changed)).join(''); +} + +/** + * 时间起卦:上卦取年支序 + 月 + 日,下卦再加时支序,总数取动爻。 + * 年支序、时支序均按地支序(子 1 至 亥 12)。 + * @param {Date} date + */ +export function castByTime(date) { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hour = date.getHours(); + const yearGanZhi = yearPillar(year, month, day); + const monthGanZhi = monthPillar(year, month, day); + const dayGanZhi = dayPillar(year, month, day); + const hourGanZhi = hourPillar(dayGanZhi.stem, hour); + + const yearNumber = yearGanZhi.branch + 1; + const hourNumber = hourGanZhi.branch + 1; + const upperNumber = normalizeToEight(yearNumber + month + day); + const lowerNumber = normalizeToEight(yearNumber + month + day + hourNumber); + const position = normalizeToSix(yearNumber + month + day + hourNumber); + + // 时间起卦按时辰取数,两小时一换。把下一次换算结果一并给出, + // 免得同一个时辰内反复起卦,看到同一个卦而以为结果写死。 + const nextDate = new Date(date); + const nextBranch = (hourGanZhi.branch + 1) % 12; + nextDate.setHours((nextBranch * 2 + 23) % 24, 0, 0, 0); + const nextHourNumber = nextBranch + 1; + const nextHexagram = hexagramByNumbers( + normalizeToEight(yearNumber + month + day), + normalizeToEight(yearNumber + month + day + nextHourNumber), + ); + const nextPosition = normalizeToSix(yearNumber + month + day + nextHourNumber); + + return { + method: '时间起卦', + summary: '以年支序加公历月、日得上下卦,再加时支序得动爻。', + cadence: { + basis: '时辰', + current: `${BRANCH_NAMES[hourGanZhi.branch]}时(${pad2(date.getHours())}:00 起,两小时一换)`, + next: `${BRANCH_NAMES[nextBranch]}时得${nextHexagram.name},动${POSITION_LABELS[nextPosition - 1]}`, + text: `时间起卦按时辰取数,两小时一换,同一时辰内结果相同,跨时辰即变。现起${BRANCH_NAMES[hourGanZhi.branch]}时之卦;到${BRANCH_NAMES[nextBranch]}时再起,将得${nextHexagram.name}。`, + }, + detail: [ + { label: '年支序', value: `${yearGanZhi.name}(${yearNumber})` }, + { label: '月 · 日', value: `${month} + ${day}` }, + { label: '时支序', value: `${hourGanZhi.name}(${hourNumber}),${BRANCH_NAMES[hourGanZhi.branch]}时` }, + { label: '上卦', value: `${(yearNumber + month + day).toString()} 除 8 余 ${upperNumber} → ${TRIGRAMS[earlyKey(upperNumber)].name}` }, + { label: '下卦', value: `${(yearNumber + month + day + hourNumber).toString()} 除 8 余 ${lowerNumber} → ${TRIGRAMS[earlyKey(lowerNumber)].name}` }, + { label: '动爻', value: `${(yearNumber + month + day + hourNumber).toString()} 除 6 余 ${position} → ${POSITION_LABELS[position - 1]}` }, + ], + hexagram: hexagramByNumbers(upperNumber, lowerNumber), + positions: [position], + sums: Array.from({ length: 6 }, () => null), + }; +} + +/** + * 数字起卦:两数分取上下卦,两数之和取动爻。 + * @param {number} upperNumber + * @param {number} lowerNumber + */ +export function castByNumbers(upperNumber, lowerNumber) { + const upper = normalizeToEight(upperNumber); + const lower = normalizeToEight(lowerNumber); + const position = normalizeToSix(upperNumber + lowerNumber); + return { + method: '数字起卦', + summary: '以两个数字分取上下卦,两数之和取动爻。', + cadence: { + basis: '所取之数', + current: `本次取数 ${upperNumber} 与 ${lowerNumber}`, + next: '换一组数字即得另一卦', + text: '数字起卦由你心里默念的两数决定,数字相同自然卦相同——这是取数,不是随机。心里先有事再默念数字,才算应了「以数起卦」的本意。', + }, + detail: [ + { label: '第一数', value: `${upperNumber} 除 8 余 ${upper} → ${TRIGRAMS[earlyKey(upper)].name}卦` }, + { label: '第二数', value: `${lowerNumber} 除 8 余 ${lower} → ${TRIGRAMS[earlyKey(lower)].name}卦` }, + { label: '动爻', value: `${upperNumber + lowerNumber} 除 6 余 ${position} → ${POSITION_LABELS[position - 1]}` }, + ], + hexagram: hexagramByNumbers(upper, lower), + positions: [position], + sums: Array.from({ length: 6 }, () => null), + }; +} + +/** + * 每日一卦:同日同结果,便于「今天怎么样」这类问题。 + * @param {Date} date + */ +export function castDaily(date) { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const upper = normalizeToEight(year + month + day); + const lower = normalizeToEight(year + 2 * month + day); + const position = normalizeToSix(year + 3 * month + day); + + // 每日一卦一日一换,零点即变。明日之卦一并给出,说明它不是在原地打转。 + const tomorrow = new Date(date); + tomorrow.setDate(tomorrow.getDate() + 1); + const tYear = tomorrow.getFullYear(); + const tMonth = tomorrow.getMonth() + 1; + const tDay = tomorrow.getDate(); + const tomorrowHexagram = hexagramByNumbers( + normalizeToEight(tYear + tMonth + tDay), + normalizeToEight(tYear + 2 * tMonth + tDay), + ); + + return { + method: '每日一卦', + summary: '按年月日确定性推算,同一天起卦结果不变。', + cadence: { + basis: '日期', + current: `${year} 年 ${month} 月 ${day} 日之卦`, + next: `明日(${tMonth}/${tDay})得${tomorrowHexagram.name}`, + text: `每日一卦一日一换,同一天内反复起卦自然是同一个卦,次日零点后即变。明日之卦为${tomorrowHexagram.name}。想随时随地起不同的卦,请用铜钱摇卦或数字起卦。`, + }, + detail: [ + { label: '日期', value: `${year} 年 ${month} 月 ${day} 日` }, + { label: '上卦', value: `${year} + ${month} + ${day} = ${year + month + day},除 8 余 ${upper} → ${TRIGRAMS[earlyKey(upper)].name}` }, + { label: '下卦', value: `${year} + ${2 * month} + ${day} = ${year + 2 * month + day},除 8 余 ${lower} → ${TRIGRAMS[earlyKey(lower)].name}` }, + { label: '动爻', value: `${year} + ${3 * month} + ${day} = ${year + 3 * month + day},除 6 余 ${position} → ${POSITION_LABELS[position - 1]}` }, + ], + hexagram: hexagramByNumbers(upper, lower), + positions: [position], + sums: Array.from({ length: 6 }, () => null), + }; +} + +/** + * 摇卦一次:三枚铜钱,6 老阴、7 少阳、8 少阴、9 老阳。 + * @returns {{ sum: number, coins: boolean[] }} coins 中 true 为背 + */ +export function tossCoins() { + const coins = [randomInt(2) === 1, randomInt(2) === 1, randomInt(2) === 1]; + const sum = coins.reduce((total, isBack) => total + (isBack ? 3 : 2), 0); + return { sum, coins }; +} + +/** + * 摇卦:由六次掷钱结果组卦,自初爻向上。 + * @param {number[]} sums 自初爻起的六次掷钱结果 + */ +export function castByCoins(sums) { + // 每一项都得是六到九之间的整数。少了 Number.isInteger 这层,NaN 与 undefined + // 会被 `sum < 6 || sum > 9` 一起放过去——非数与任何数比较都是 false——于是一次 + // 手搓的请求会安静地组出一个全 0 的坤卦,看着像卦,其实什么都没掷。 + if (!Array.isArray(sums) + || sums.length !== 6 + || sums.some((sum) => !Number.isInteger(sum) || sum < 6 || sum > 9)) { + throw new Error('六次掷钱结果必须是 6 到 9 之间的整数'); + } + const key = sums.map((sum) => (sum === 7 || sum === 9 ? '1' : '0')).join(''); + const positions = sums.reduce((list, sum, index) => (sum === 6 || sum === 9 ? [...list, index + 1] : list), /** @type {number[]} */ ([])); + return { + method: '摇卦', + summary: '三枚铜钱掷六次,自初爻向上成卦,六为老阴、九为老阳。', + cadence: { + basis: '铜钱', + current: `本次掷出 ${sums.map((sum) => sum).join('、')}`, + next: '重掷一次即得另一卦', + text: '摇卦由铜钱随机而成,每一卦都不一样,是四法中最接近古法的一种。', + }, + detail: [ + { label: '掷钱', value: sums.map((sum) => `${sum}`).join(' · ') }, + { label: '动爻', value: positions.length ? positions.map((p) => POSITION_LABELS[p - 1]).join('、') : '无(六爻皆静)' }, + ], + hexagram: hexagramByKey(key), + positions, + sums, + }; +} + +/** + * 五行旺衰:以月令为令。当令者旺,月令所生者相,生月令者休,克月令者囚,被月令克者死。 + * 以春木为例:木旺、火相、水休、金囚、土死。 + * @param {string} element + * @param {string} monthElement + */ +/** + * 体用生克断吉凶。 + * @param {string} bodyElement 体卦五行 + * @param {string} useElement 用卦五行 + */ +function judgeRelation(bodyElement, useElement) { + if (bodyElement === useElement) { + return { + key: '比和', + verdict: '吉', + weight: 1, + text: `体卦${bodyElement}与用卦${useElement}同气相求,是比和之象。彼此立场一致,事情少有阻滞,按既定节奏推进即可。`, + }; + } + if (generates(useElement, bodyElement)) { + return { + key: '用生体', + verdict: '大吉', + weight: 2, + text: `用卦${useElement}生体卦${bodyElement},是对方主动来就我。得力、得助、得人,阻力最小,是四种关系里最顺的一种。`, + }; + } + if (overcomes(bodyElement, useElement)) { + return { + key: '体克用', + verdict: '小吉', + weight: 1, + text: `体卦${bodyElement}克用卦${useElement},是我能制住局面。事情多半能办成,但要费些周折,属于主动权在我、代价也由我承担。`, + }; + } + if (generates(bodyElement, useElement)) { + return { + key: '体生用', + verdict: '凶', + weight: -1, + text: `体卦${bodyElement}生用卦${useElement},是我耗自己去成全对方。主动投入却收不回,是四种关系里最费力的一种,不宜勉强。`, + }; + } + return { + key: '用克体', + verdict: '凶', + weight: -2, + text: `用卦${useElement}克体卦${bodyElement},是对方压制我。处境被动、受制于人,宜守不宜攻,先避其锋。`, + }; +} + +/** + * 所问何事落到这一卦上的那个「东西」。梅花里用卦是「事」,可「事」是空的: + * 不问它,结论就只能停在「体卦水克用卦火」这种谁都套得上的话上——问跳槽跟问 + * 进货拿到同一句,等于没答。这里按已认出的事类,把用卦换算成那一类事里具体的那个 + * 东西。**只换说法,不换吉凶**:换问法不会把同一个卦说出相反的结论。 + */ +const TOPIC_OBJECTS = Object.freeze({ + wealth: '这笔进项', + career: '这份前程', + love: '这段关系', + marriage: '这桩婚事', + health: '这桩病症', + study: '这场考试或这纸文书', + property: '这处房产或这纸契', + dispute: '这桩是非', + journey: '这趟行程或这件失物', +}); + +/** 生克那一层的关系怎么说。强弱不在这儿定,强弱由 judgeScore 给。 */ +const RELATION_FLAVOUR = Object.freeze({ + 用生体: '外头有人来成就你', + 体克用: '这件事你压得住', + 比和: '两边立场站得一般齐', + 体生用: '这件事要你往外掏', + 用克体: '外头的力压着这件事', +}); + +/** + * 把生克关系与吉凶强弱收成一句落到所问之事上的话。 + * @param {{key: string, label: string}|null} topic detectTopic 认出的事类 + * @param {string} relationKey 体用生克的关系名 + * @param {string} label judgeScore 给的吉凶 + * @returns {string|null} 认不出事类就返回 null,页面不摆这句 + */ +function topicSentence(topic, relationKey, label) { + if (!topic) return null; + const object = TOPIC_OBJECTS[topic.key]; + const flavour = RELATION_FLAVOUR[relationKey]; + if (!object || !flavour) return null; + // 强弱一律跟着 judgeScore 给的那一档走,不拿生克那一层的教科书断语当结论: + // 体克用本是生克小吉,体卦逢死地时总分落回「平」,这时候还说「压得住」就是过了。 + const strength = label === '大吉' || label === '吉' + ? '这一卦落在它上面是顺的' + : label === '平' + ? '它上还看不出强弱' + : '这一卦落在它上面不顺'; + return `问的是${topic.label},卦里说的就是${object}——${flavour},${strength}。`; +} + +/** + * 旺衰在白话里说的是「你此刻有多少劲」,不是「五行在月令里排第几」。 + * 断语里那五句(「体卦得令,所求之事根基稳」之类)是术语文,跟用户隔着一层。 + */ +const VITALITY_PLAIN = Object.freeze({ + 旺: '你此刻最有力气', + 相: '有人托着你,不算最强但站得住', + 休: '你使不上劲,这一回得借外力', + 囚: '你被局面困住,处境受制', + 死: '你气力最弱,此时强推反而吃亏', +}); + +/** 白话结论块:把体用生克这套术语翻成「你、那件事、你此刻的劲」。 */ +function buildPlain({ question, topic, topicLine, verdict, body, use, timing, advice }) { + const flavour = RELATION_FLAVOUR[verdict.key] ?? '两者关系不上不下'; + // 生克那一层与总分不一致时(体克用本是生克小吉,体卦逢死地总分落回「平」), + // 正好是用户最容易当成「算错了」的地方,白话里把这层差价讲明白。 + // 整块里都不许出现「体卦/用卦/旺衰/类神/月令」——它们在断语正文里出现过, + // 搬进白话就等于没翻。校验在 tests 里,措辞改了那边会报红。 + const adjust = verdict.relationVerdict !== verdict.label + ? `照两人之间的关系本该是${verdict.relationVerdict},你这个月的状态把它拉到了「${verdict.label}」。` + : ''; + return { + ask: question ? `你问的是「${question}」。` : '你没写具体问什么,我按起卦当时的时辰给你看这一卦。', + topic: topic + ? `我把它归到「${topic.label}」这一类——这一类以${topic.element}为事。` + : '你问的事不在那九类里,事类先空着,时间上按那件事本身算。', + verdict: `这一卦给的是「${verdict.label}」,${verdict.summary.split(':').slice(1).join(':') || '没有偏向'}。`, + // 这一句是整块里最要紧的翻译:体卦是你、用卦是那件事、旺衰是你此刻的劲。 + why: `体卦${body.name}${body.element}是你,用卦${use.name}${use.element}是那件事——${flavour};${VITALITY_PLAIN[verdict.vitality] ?? '你此刻的状态不上不下'}。${adjust}`, + onTopic: topicLine ?? '', + timing: timing + ? `时间上:${timing.wang}月或${timing.wang}日见端倪,到${timing.xiang}前后渐明。` + : '', + actions: `该做的是:${advice.suitable.join('、')}。别做的是:${advice.avoid.join('、')}。`, + caution: advice.caution, + }; +} + +/** + * 组装完整卦象解读。 + * @param {{ method: string, summary: string, detail: {label: string, value: string}[], hexagram: any, positions: number[], sums: (number|null)[] }} cast + * @param {{ question?: string, id?: string, now?: Date }} [options] + * @returns {Reading} + */ +/** + * 吉凶只此一处断:由「生克 + 旺衰」合出来的总分定,页面徽章、结论行、MCP 抬头 + * 全取这一份。 + * + * 从前徽章取的是 judgeRelation() 里的生克断语(只论体用生克这一条),结论行取的 + * 却是含旺衰的总分——体克用而体卦当令得旺时,同一屏上一个说「小吉」一个说 + * 「大吉」。两句话都在讲吉凶,却不是同一个吉凶,读的人只会挑一个信。 + * + * 生克本身的教科书断语仍单列为 relationVerdict:它是这一卦凭什么得分的「因」, + * 跟总分这个「果」不是一回事,摆在事实表里当作依据,不与结论抢位置。 + * + * @param {number} score 生克权重加上体卦旺衰修正 + * @returns {{ label: string, summary: string }} + */ +function judgeScore(score) { + if (score >= 2) return { label: '大吉', summary: '大吉:可进' }; + if (score >= 1) return { label: '吉', summary: '吉:宜行' }; + if (score <= -2) return { label: '大凶', summary: '大凶:宜止' }; + if (score <= -1) return { label: '凶', summary: '凶:宜守' }; + return { label: '平', summary: '平:待时' }; +} + +/** + * 推一卦。 + * @param {any} cast 起卦四法的结果 + * @param {object} [options] + * @param {string} [options.question] 所问何事原话,用于关键词认事类 + * @param {Date} [options.now] + * @param {string} [options.topic] 事类键(九个之一)。给了就以它为准,不再看关键词; + * 给的键不在九类里则当作没给,退回关键词那条路。 + * @returns {any} 一卦的全部解读 + */ +export function buildReading(cast, options = {}) { + const now = options.now ?? new Date(); + const question = (options.question ?? '').trim(); + const { hexagram, positions, sums } = cast; + const lines = buildLines(hexagram.key, positions, sums, hexagram.order); + const movingLines = lines.filter((line) => line.moving); + const hasChange = movingLines.length > 0; + + const changed = hasChange ? hexagramByKey(changedKey(lines)) : null; + const mutual = mutualHexagram(hexagram); + const opposite = oppositeHexagram(hexagram); + const inverted = invertedHexagram(hexagram); + + // 梅花易数:动爻所在经卦为体(我),另一经卦为用(事)。 + const primaryPosition = positions[0] ?? 1; + const bodyIsLower = primaryPosition <= 3; + const bodyKey = bodyIsLower ? hexagram.lower : hexagram.upper; + const useKey = bodyIsLower ? hexagram.upper : hexagram.lower; + const body = TRIGRAMS[bodyKey]; + const use = TRIGRAMS[useKey]; + const shiyinPosition = counterpart(primaryPosition); + + const year = now.getFullYear(); + const month = now.getMonth() + 1; + const day = now.getDate(); + const monthBranch = monthPillar(year, month, day).branch; + const monthElement = monthPillar(year, month, day).element; + const bodyVitality = vitality(body.element, monthElement); + const useVitality = vitality(use.element, monthElement); + const relation = judgeRelation(body.element, use.element); + + // 卦气是另一条路:月建五行讲的是「这五行在当月强弱如何」,十二辟卦讲的是 + // 「当月本该是哪一卦当令」。两者不互相替代,故并列给出。 + const monthLord = monthQi(monthBranch); + const selfQi = hexagramQi(hexagram.key); + + // 所问何事落到事类,事类取类神五行。类神只管应期与取象,不改吉凶—— + // 同一个卦问财与问婚,凶不会因此翻面,只是看的时辰和轻重不同。 + // + // 事类有两个来路。关键词那条是给页面用的:页面上只有一句「所问何事」, + // 没有能替你归纳的人。MCP 那条是给 Agent 用的——它本来就看得懂用户在说 + // 什么,让它自己挑,比在这儿拿一串词去撞「他对我还有没有真心」这类问法 + // 靠谱得多。**显式给的优先**:Agent 是在读过原话之后判断的,比关键词更 + // 贴近用户真正问的那件事。认不出(没给、或给的键不在九类里)才退回关键词。 + const explicitTopic = topicByKey(options.topic); + const topic = explicitTopic ?? detectTopic(question); + const topicSource = explicitTopic ? 'explicit' : (topic ? 'detected' : null); + const god = topic ? godRelation(topic.element, body.element, generates, overcomes) : null; + const timing = topic + ? responseTiming(topic.element, `${topic.label}类神`) + : responseTiming(use.element, `用卦${use.name}`); + const linePosition = LINE_POSITIONS[primaryPosition - 1]; + + const structure = { + body: { key: bodyKey, name: body.name, element: body.element, nature: body.nature, image: body.image, direction: body.direction, position: bodyIsLower ? '内卦(下卦)' : '外卦(上卦)' }, + use: { key: useKey, name: use.name, element: use.element, nature: use.nature, image: use.image, direction: use.direction, position: bodyIsLower ? '外卦(上卦)' : '内卦(下卦)' }, + shi: { position: primaryPosition, label: POSITION_LABELS[primaryPosition - 1], role: '动爻 · 体卦' }, + ying: { position: shiyinPosition, label: POSITION_LABELS[shiyinPosition - 1], role: '配爻 · 用卦' }, + monthElement, + bodyVitality: bodyVitality.key, + useVitality: useVitality.key, + }; + + const weight = relation.weight + (bodyVitality.tone === 'strong' || bodyVitality.tone === 'good' ? 1 : bodyVitality.tone === 'bad' ? -1 : 0); + const judged = judgeScore(weight); + const verdict = { + key: relation.key, + label: judged.label, + score: weight, + summary: judged.summary, + text: relation.text, + relationVerdict: relation.verdict, + vitality: bodyVitality.key, + vitalityText: `体卦五行属${body.element},当月令为${monthElement},旺衰落在「${bodyVitality.key}」。${{ 旺: '体卦得令,所求之事根基稳。', 相: '体卦得月令之助,虽非最强但有托底。', 休: '体卦失令而休,力量不足,宜借外力。', 囚: '体卦受月令所困,处境受制,宜守。', 死: '体卦逢月令死地,气力最弱,此时强求不利。' }[bodyVitality.key]}`, + }; + + const insights = []; + insights.push({ title: '卦象总断', text: `本卦${hexagram.name},${hexagram.judgment} ${hexagram.image}` }); + // 卦辞说大势,爻辞才对着动的那一爻说话,所以把它排在紧随卦辞之后;象传跟在 + // 同一段里,是这一爻「凭什么」的解释。 + const yaoQuotes = movingLines.map((line) => { + const say = line.text ? `${line.text} 象曰:${afterColon(line.xiang)}` : ''; + return say; + }).filter(Boolean); + if (yaoQuotes.length > 0) { + insights.push({ title: '动爻爻辞', text: yaoQuotes.join(';') }); + } + if (topic && god) { + insights.push({ + title: '所问之事', + text: `所问归「${topic.label}」,类神取${topic.element}。${topic.reason}${god.text}`, + }); + } + insights.push({ title: '体用关系', text: relation.text }); + insights.push({ title: '旺衰应期', text: verdict.vitalityText }); + if (monthLord) { + // 卦气只讲位置,不改吉凶:本卦是不是当令主卦,是另一维度的话,不与体用相混。 + const trend = monthLord.phase === '息' + ? '当月阳气渐长,主事有推进之势' + : '当月阴气渐盛,主事宜收敛守成'; + const self = selfQi + ? `本卦${hexagram.name}正在十二辟卦之中,为${selfQi.short}卦,属${selfQi.phase},${selfQi.meaning}` + : `本卦${hexagram.name}不属十二辟卦,十二辟卦是乾坤二宫专主月份的十二卦,其余五十二卦不论卦气`; + const align = selfQi + ? (selfQi.phase === monthLord.phase + ? `与当月${selfQi.phase === '息' ? '同处阳长之段,方向与时势相合' : '同处阴长之段,行事与时势相应'}。` + : `与当月${monthLord.short}卦分处消长两端,是逆着时势走,宜放缓一步。`) + : ''; + insights.push({ + title: '卦气 · 当令主卦', + text: `${BRANCHES[monthBranch]}月当令主卦为${monthLord.name},${monthLord.meaning}${trend}。${self}${align}`, + }); + } + insights.push({ + title: '互卦 · 过程', + text: `互卦为${mutual.name},主事情中间一段的走向。${mutual.image}`, + }); + if (changed) { + insights.push({ title: '变卦 · 结果', text: `动爻${movingLines.map((line) => line.label).join('、')},变出${changed.name},主事情最终落点。${changed.image}` }); + } else { + insights.push({ title: '变卦 · 结果', text: '六爻皆静,无变卦。事态格局稳定,不会中途生变,守住当前位置即可。' }); + } + insights.push({ + title: '错卦 · 旁支', + text: `错卦为${opposite.name},是同一件事的另一面,提醒你别只盯着眼前这一层。${opposite.image}`, + }); + insights.push({ + title: '综卦 · 反求', + text: `综卦为${inverted.name},是此事的倒影:换到对方的位置看,往往能看出自己忽略的条件。${inverted.image}`, + }); + // 京房一层:本卦定宫定世次,六爻各装纳甲与六亲。世爻是我、应爻是对方, + // 世应之间的生克讲的是「我跟这个人」,与上面体用讲的「我跟这件事」不是一回事。 + const jf = jingfang(hexagram); + const shiLine = jf.lines[jf.shi - 1]; + const yingLine = jf.lines[jf.ying - 1]; + const shengke = shiYingRelation(shiLine.element, yingLine.element); + const movingRelatives = movingLines + .map((line) => jf.lines[line.position - 1]) + .filter((line) => line.relative) + .map((line) => `${line.label}${line.relative}`); + const uniqueRelatives = [...new Set(movingRelatives.map((item) => item.replace(/^.*?爻/u, '')))]; + insights.push({ + title: '六亲世应', + text: [ + `${jf.palaceName}${jf.stage}卦,属${jf.element}。世爻${shiLine.label}持${shiLine.relative},应爻${yingLine.label}为${yingLine.relative}。`, + shengke ? `${shengke.text}。` : '', + movingRelatives.length > 0 + ? `动爻${movingRelatives.join('、')},事落在${uniqueRelatives.join('、')}上:${uniqueRelatives.map((name) => RELATIVE_MEANING[name]).filter(Boolean).join(';')}。` + : '六爻皆静,无动爻,身份格局照旧。', + jf.stage === '游魂' || jf.stage === '归魂' ? jf.stageMeaning : '', + ].filter(Boolean).join(''), + }); + + // 用神:问何事取何亲。六亲摆在那里只是摆着,落到「你问的这一件事」上才算用上了。 + const movingPositions = movingLines.map((line) => line.position); + const useGod = topic ? pickUseGod(jf, topic.god.relatives, movingPositions) : null; + const dayBranch = dayPillar(year, month, day).branch; + const dayGanZhi = dayPillar(year, month, day); + const calendar = { + monthElement, + dayElement: BRANCH_ELEMENTS[dayBranch], + // 旬空要靠日柱在六十甲子里的序号才排得出,月破与入墓要靠地支序号,都一并带上。 + dayIndex: dayGanZhi.index, + dayStem: dayGanZhi.stem, + dayBranch, + monthBranch, + movingPositions, + movingElements: movingLines.map((line) => { + const na = jf.lines[line.position - 1]; + return na ? na.element : body.element; + }), + }; + // 日辰冲爻分三路:静爻旺相作暗动、静爻休囚作日破、动爻作冲散。判定在 jingfang 层。 + const clash = dayClashReading(jf, calendar); + const darkPositions = clash.dark.map((line) => line.position); + const pressedPositions = clash.pressed.map((line) => line.position); + // 用神那圈(元神、忌神、仇神)只在一个用神定下来时才存在:不上卦取的是伏神, + // 伏神在卦外,元忌仇无从谈起;两亲各看各的时也无从取舍。所以只在 picked 非空时算。 + // 排在暗动之后,是因为这一圈要报「暗动」与「动而逢冲」——元神忌神究竟在明处动、 + // 是在暗处动、还是正被日辰冲着,正要用刚算出来的日冲爻位。 + const circle = useGod && useGod.picked + ? circleReading(jf, useGod.picked, calendar, darkPositions, pressedPositions) + : null; + insights.push({ + title: '用神', + text: useGod + ? useGodText(topic, useGod, jf, movingPositions, calendar, circle, darkPositions, pressedPositions) + // 写没写问题与写了但认不出是两回事。都说成「未写所问何事」,等于把话没说到位 + // 赖到问卦的人头上——他明明写了,是这张事类表没接住那句话。 + : (question + ? `写了问题,但这句话里没有本包认得的事类词。用神是「问何事取何亲」,认不出所问何事就取不出用神——` + + `表里接得住的是${TOPIC_CLASSES.map((item) => item.label).join('、')}这九类,` + + '把话里带上具体那一件事再看这一段。' + : '未写所问何事,取不出用神——六亲各管一摊事,没有所指就没有用神。写下问题再看这一段。'), + }); + + // 这一段紧接用神:暗动章的吉凶两路(喜、忌)判的正是元神与忌神是不是在暗中动手, + // 冲散章则说动爻逢日冲并不作散——都是拿日辰对着用神周围那圈讲的, + // 挨着「用神是谁、谁坐着」才接得上。 + if (clash.dark.length > 0 || clash.dayBroken.length > 0 || clash.pressed.length > 0) { + insights.push({ title: '暗动 · 日破 · 冲散', text: dayClashText(clash, circle, calendar) }); + } + + // 变卦的京房卦提前算出来:下面「六冲」那一段要判变卦是不是六冲/六合, + // 「逢合」那一段要拿变出的那一爻回头看,化爻那一段也要用。 + const changedJf = changed ? jingfang(changed) : null; + + // 爻之合的另外三法。卦级那三法(卦逢六合、六冲变六合、六合变六合)在 hexagramClash 里, + // 已经画在卦体边上、也在「卦体冲合」那一格列出;这里补的是落在单爻上的三法, + // 按动静与来路分成合起、合绊、合好、化扶四名。排在暗动、六冲之后: + // 前一段拿日月对着爻看冲,后一段先认整卦的冲合,再落到单爻的合上。 + const combine = heCombineReading(jf, calendar, changedJf); + if (combine.hasAny) { + insights.push({ title: '逢合 · 合起合绊合好化扶', text: heCombineText(combine, calendar) }); + } + + // 爻之刑。三刑章那句话自带一个很紧的前提,判定放在 punishText 里核。 + // 排在逢合之后:那一段讲合住,这一段讲刑伤,都是拿日月与别爻对着这一爻看。 + const punish = punishReading(jf, calendar); + if (punish.hasAny) { + insights.push({ title: '犯刑', text: punishText(punish, jf, circle, calendar) }); + } + + // 变出之爻:本卦这一爻是「谁」,变出来的那一爻是它「往哪儿去」。 + // 提前算在这里,不只是为了下面那一段——章末那句「用神化回头冲克」的判定 + // 只有 transformReading 这一处有,在别处重算一遍就是两套说法。 + const transforms = changedJf + ? movingPositions.map((position) => transformReading(position, jf, changedJf, calendar)) + : []; + + // 反伏与卦变。反伏章第二十五把两者摆在一起说,但条件对不上(见 jingfang 的注), + // 所以分两档报。排在犯刑之后:前面几段都是拿单爻说话,这一段才看整卦的内与外。 + const fanfu = fanfuReading(jf, calendar, changedJf); + if (fanfu.hasAny) { + insights.push({ + title: '反伏与卦变', + text: fanfuText(fanfu, changed.name, topic, circle, calendar, movingPositions, transforms), + }); + } + + // 六冲章的六种冲,前一路(日月冲爻)刚在上面逐爻算过,这里接着数剩下几路。 + // 触发条件取「卦体本身是六冲或六合」加「变卦是六冲」加「动爻变冲」—— + // 卦内零散爻与爻冲六十四卦里有三十卦都有,单拿它当触发会让大半卦都多出这一段, + // 所以那一路只在卦体已经因为别的理由出段时顺带报,不单独开段。 + const kinds = clashKinds({ hexagram, jf, changed, changedJf, movingPositions }); + if (kinds.chong || kinds.he || kinds.changedChong || kinds.transformClash.length > 0) { + insights.push({ title: '六冲', text: clashText(kinds, circle, topic, calendar) }); + } + + insights.push({ title: '化爻 · 变出之爻', text: transformText(transforms, circle) }); + + // 「世应」这两个字留给京房那边:世爻恒由本卦的宫与世次定,与动爻无关。梅花这一层 + // 讲的是我与事,说「主客」才不打架——同一段解读里出现两个不同的世爻位会看糊涂。 + // 对应爻是初应四、二应五、三应上,来回都跨内卦与外卦,所以主客恒分居两卦, + // 不存在「同在一卦」的情形,这里只陈事实,趋势的话留给体用关系与取象两段。 + insights.push({ + title: '主客', + text: `体卦在${POSITION_LABELS[primaryPosition - 1]},是我;用卦在${POSITION_LABELS[shiyinPosition - 1]},是所测之事。世为己、应为彼,两爻相隔三位,恒分居内卦与外卦。`, + }); + insights.push({ + title: '取象', + text: `下卦${body.name}取${body.image}象,主自身与内里;上卦${use.name}取${use.image}象,主环境与外在。体${body.nature}而用${use.nature},${body.nature === use.nature ? '两卦同性,内外如一' : `内外异质,外在环境是${use.nature}的,对你形成${generates(use.element, body.element) ? '生扶' : overcomes(use.element, body.element) ? '压制' : '耗损'}。`}${god ? `就所问之事而论,${god.text}` : ''}`, + }); + insights.push({ + title: `爻位 · ${linePosition.title}`, + text: `动爻在${POSITION_LABELS[primaryPosition - 1]}。${linePosition.text}`, + }); + insights.push({ + title: '方所', + text: `体卦${body.name}居${body.direction},用卦${use.name}居${use.direction}。寻物问路可取此方位;体卦方位主你所归,用卦方位主你要去的地方。`, + }); + + if (timing) { + insights.splice(2, 0, { title: '应期', text: timing.text }); + } + + const advice = buildAdvice(verdict); + const topicLine = topic ? topicSentence(topic, verdict.key, verdict.label) : null; + + return { + id: options.id ?? buildId(hexagram, positions, now), + method: cast.method, + question, + createdAt: now.toISOString(), + hexagram: serialize(hexagram), + changed: changed ? serialize(changed) : null, + mutual: serialize(mutual), + opposite: serialize(opposite), + inverted: serialize(inverted), + lines, + movingLines, + structure, + jingfang: jf, + changedJingfang: changedJf, + // 六神自初爻向上各归一爻。只说是什么气氛,不参与生克,也不动吉凶。 + sixGods: sixGods(dayGanZhi.stem), + // 旬空与月破是这一卦整体的两处日子,跟哪一卦无关,单列一份给右栏和历法页用。 + void: (() => { + const kong = xunKong(dayGanZhi.index); + const po = monthPo(monthBranch); + return { + headName: kong.headName, + names: kong.voidNames, + brokenName: BRANCHES[po], + }; + })(), + // 六爻各自逢什么:旬空(连真假)、月破、入墓、暗动、日破。卦体照这个画小标,断语照这个说话。 + // 注意两个「破」不是一回事:broken 是月建冲的月破,这里 dayBroken 才是日辰冲的日破。 + states: jf.lines.map((line) => { + const v = voidReading(line, calendar); + return { + position: line.position, + void: v.isVoid, + voidKind: v.status, + broken: v.isBroken, + tomb: v.isTomb, + dark: darkPositions.includes(line.position), + dayBroken: clash.dayBroken.some((one) => one.position === line.position), + // 冲散:动爻被日辰冲到。与暗动、日破互斥,三者不会同落一爻。 + pressed: pressedPositions.includes(line.position), + // 逢合:这一爻合于日月、与另一动爻相合、或动爻化出之爻回头相合,三路任一即是。 + // 与上面那三个不互斥——冲的是被冲来的那一边,合的是被缠住的那一边,同一爻可以又逢冲又逢合。 + combined: combine.hitPositions.includes(line.position), + // 犯刑:与「逢合」不是互斥的两路。合是被缠住,刑是被伤着,同一爻可以又合又刑。 + punished: punish.hitPositions.includes(line.position), + rescues: v.rescues, + empties: v.empties, + }; + }), + // 日辰冲爻的三路各是哪些爻。暗动约逢三卦里一卦,日破略多,冲散(动爻逢冲)又约四卦里一卦; + // 三路皆空时断语里不出这一段。 + dayClash: { + dark: darkPositions, + dayBroken: clash.dayBroken.map((line) => line.position), + pressed: pressedPositions, + }, + // 爻之合那四名各是哪些爻。合起与合绊互斥(一爻要么静要么动), + // 合好与化扶可以与它们同落一爻。日月合爻最常见,合好要两爻皆动、化扶要动爻化出之爻 + // 回头相合,都少得多。四路皆空时断语里不出这一段。 + // 反伏与卦变。两档不合成一条:卦变换过去的纳支并不逐位相冲, + // 合成一条报,乾变坤就得被漏掉。内/外各报出换过去的那三支,方向不折。 + fanfu: { + kind: fanfu.kind, + inner: fanfu.inner, + outer: fanfu.outer, + guaChange: fanfu.guaChange, + innerSwap: fanfu.inner ? [fanfu.innerFrom, fanfu.innerTo] : null, + outerSwap: fanfu.outer ? [fanfu.outerFrom, fanfu.outerTo] : null, + }, + // 爻之刑。方向原样带出去(谁刑谁),不折成「这几爻犯刑」——折了就看不出是谁动的。 + punish: { + linePairs: punish.linePairs.map((pair) => [pair.from.position, pair.to.position, pair.self]), + outside: punish.outside.map((item) => [item.line.position, item.source, item.from, item.to, item.self]), + }, + combine: { + rise: combine.rise.map((item) => [item.line.position, item.source]), + bind: combine.bind.map((item) => [item.line.position, item.source]), + friendly: combine.friendly.map((item) => [item.lineA.position, item.lineB.position]), + support: combine.support.map((item) => item.line.position), + }, + // 六冲章那六种冲逐条对出来的结果。本卦逢六冲十五卦中的十卦、六合八卦,都是整卦的 + // 定性;变卦那两路与动爻变冲要等动起来才谈得上。爻位一律用数字,便于程序取用。 + clash: { + chong: kinds.chong, + he: kinds.he, + changedChong: kinds.changedChong, + changedHe: kinds.changedHe, + heToChong: kinds.heToChong, + chongToChong: kinds.chongToChong, + transformClash: kinds.transformClash.map((item) => item.position), + incidental: kinds.incidental.map((pair) => [pair.a, pair.b]), + pairs: kinds.pairs.map((pair) => ({ + lower: pair.lower.position, + upper: pair.upper.position, + kind: pair.kind, + })), + // 变卦那三对同样给出去:卦体上本卦与变卦各画一组冲合连线,六冲变六冲那一路 + // 才看得见「两头都是三对皆冲」,而不是只在本卦上出。 + changedPairs: (changedJf + ? hexagramClash(changed, changedJf).pairs + : []).map((pair) => ({ + lower: pair.lower.position, + upper: pair.upper.position, + kind: pair.kind, + })), + }, + useGod: useGod + ? { + topic: topic.key, + relatives: useGod.relatives, + present: useGod.present, + absent: useGod.absent, + picked: useGod.picked, + why: useGod.why, + // 用神周围那一圈:元神、忌神、仇神。用神定不下来时无此圈(伏神在卦外, + // 两亲各看各的也无从取舍),所以是一段字段而不是逐爻摊开。 + circle: circle + ? { + yuan: circle.yuan.map((line) => line.position), + ji: circle.ji.map((line) => line.position), + chou: circle.chou.map((line) => line.position), + elements: circle.elements, + } + : null, + // 用神不上卦时,从本宫首卦借来的伏神与压在它上面的飞神。 + // sentence 只给断语正文用,不进结构化字段——那段话断语里已经整段说过了。 + hidden: useGod && useGod.picked === null && useGod.present.length === 0 + ? useGod.absent + .map((name) => { + const one = hiddenReading(name, jf, calendar); + if (!one) return null; + const { sentence, ...rest } = one; + return rest; + }) + .filter(Boolean) + : [], + } + : null, + verdict, + // 取象句挂在 verdict 上而不是平铺一层:它跟吉凶是一件事的两半——吉凶是那一档, + // 取象句是这一档落在所问之事上是什么话。拆开放,页面就得自己拼回去。 + // 认不出事类时这个键压根不存在(不是 null),页面不摆这句,也不拿它凑数。 + ...(topic ? { topicLine } : {}), + // 白话块:MCP 把它接在正文末尾,Agent 照着讲给用户听。每一句都从上面已算好的 + // 字段来,不另起一套判断,也不替没定吉凶的东西表态。 + plain: buildPlain({ question, topic, topicLine, verdict, body, use, timing, advice }), + // 每一动爻变出来的那一爻:回头生克、进退神、化空化墓。断语正文与这里走同一份, + // sentence 只进断语不进字段——那段话断语里已经整段说过了。 + transforms: transforms.map(({ sentence, ...rest }) => rest), + topic: topic ? { key: topic.key, label: topic.label, element: topic.element, reason: topic.reason } : null, + // 事类是谁定的:Agent 显式给的、关键词撞出来的,还是压根没定。前两种差别说大不大—— + // 关键词那一路会有「问的是感情,但原话里没一个词在表上」的漏判,Agent 显式给就绕开了。 + topicSource, + qi: monthLord + ? { + branch: monthBranch, + lord: monthLord.name, + lordShort: monthLord.short, + phase: monthLord.phase, + yangCount: monthLord.yangCount, + self: selfQi ? selfQi.short : null, + selfName: selfQi ? selfQi.name : null, + selfPhase: selfQi ? selfQi.phase : null, + } + : null, + timing: timing?.text ?? '', + cadence: cast.cadence ?? null, + insights, + details: cast.detail, + advice, + }; +} + +/** 用神与世爻的关系:世爻是我,用神是所求之事,两者的生克讲「这件事对我是什么」。 */ +const GOD_SHI_TONE = Object.freeze({ + 生: '世爻生用神,用神得扶,所求之事有底子。', + 被生: '用神生世爻,反是我被这件事牵着走,多主我出力多过收成。', + 克: '世爻克用神,这件事在我压制之下,主动权在握但要费力气。', + 被克: '用神克世爻,这件事压着我,宜守不宜攻。', + 同气: '用神与世爻同气,所求之事与我的处境同一路数,顺势为宜。', +}); + +/** + * 伏神出不出得来。 + * + * 《增删卜易·飞伏神章》列「伏神易出有六」与「终不得出有五」,本包七条都能核验: + * 易出六——得日月生、得旺相、得飞神生、得动爻生,用月建与日辰即可判; + * 「飞神逢旬空、月破或休囚墓绝」这一条要旬空、月破、入墓,也已做。 + * 不出五——休囚无气、被日月冲克、被旺相飞神克害、正逢墓绝、直逢旬空月破,逐条对上。 + * 六用五不出之外,野鹤还把休、囚、死并入无气,本包同此口径。 + */ + +/** + * 伏神的取法、飞伏生克与出伏结论,收在一处。 + * 断语正文与 reading.useGod.hidden 两边都从这里取,免得同一卦算出两个说法。 + * @returns {{relative: string, position: number, hushen: string, feishen: string, + * feishenRelative: string, flying: string, emerges: {key: string, text: string}, + * sentence: string} | null} + */ +function hiddenReading(name, jingfang, calendar) { + const pair = hiddenGod(jingfang, name); + if (!pair) return null; + const flying = flyingRelation(pair.hushen, pair.feishen); + // 伏神与飞神各自逢什么空、破、墓,一并问出来;伏神按野鹤的分法再判真假。 + const fu = voidReading(pair.hushen, { ...calendar, isHidden: true, isStruck: flying.key === '飞来克伏' }); + const god = sixGods(calendar.dayStem)[pair.hushen.position - 1]; + const fei = voidReading(pair.feishen, calendar); + const emerges = hiddenVerdict( + pair.hushen, + pair.feishen, + flying, + calendar.monthElement, + calendar.dayElement, + calendar.movingElements, + { + isVoid: fu.isVoid, + status: fu.status, + isBroken: fu.isBroken, + isTomb: fu.isTomb, + flyingVoid: fei.isVoid, + flyingBroken: fei.isBroken, + flyingTomb: fei.isTomb, + }, + ); + return { + relative: name, + position: pair.hushen.position, + hushen: `${pair.hushen.stem}${pair.hushen.branch}${pair.hushen.element}`, + feishen: `${pair.feishen.stem}${pair.feishen.branch}${pair.feishen.element}`, + feishenRelative: pair.feishen.relative, + god, + flying: flying.key, + emerges, + sentence: `${name}伏在${pair.hushen.position}爻之下——本宫首卦${pair.palaceName}的${pair.hushen.stem}${pair.hushen.branch}${pair.hushen.element}在此位,` + + `压着它的${pair.feishen.stem}${pair.feishen.branch}${pair.feishen.element}${pair.feishen.relative}是飞神。` + + `${flying.text}。伏神临${god},${SIX_GOD_MEANING[god].meaning},只是它的调子,成不成仍只由生克与旺衰定。` + + `${emerges.text}`, + }; +} + +/** 伏神那一整句:取自本宫首卦,飞伏生克 + 出不出得来。 */ +function hiddenText(god, jingfang, calendar) { + const found = []; + for (const name of god.absent) { + const one = hiddenReading(name, jingfang, calendar); + if (!one) { + found.push(`${name}在本宫首卦里也寻不到`); + continue; + } + found.push(one.sentence); + } + return `按《增删卜易·飞伏神章》从本宫首卦取伏神:${found.join(' ')}`; +} + +function hiddenVerdict(hushen, feishen, flying, monthElement, dayElement, movingElements, state) { + // 《增删卜易》「伏神易出有六」与「终不得出有五」逐条落:旺衰靠月建,生扶靠月建与 + // 日辰,旬空月破入墓各据其表,飞伏空破则压不住伏神。七条之外野鹤还把休囚死并入无气。 + const good = []; + if (generates(monthElement, hushen.element)) good.push('得月建生'); + if (generates(dayElement, hushen.element)) good.push('得日辰生'); + const tone = vitality(hushen.element, monthElement); + if (tone.tone === 'strong' || tone.tone === 'good') good.push(`于月建${tone.key}`); + if (flying.key === '飞来生伏') good.push('得飞神生'); + if (movingElements.some((element) => generates(element, hushen.element))) good.push('得动爻生'); + if (state.flyingVoid || state.flyingBroken || state.flyingTomb) { + good.push('飞神逢空破墓,压不住它'); + } + + const bad = []; + if (overcomes(monthElement, hushen.element) || overcomes(dayElement, hushen.element)) { + bad.push('被月建或日辰克'); + } + if (['休', '囚', '死'].includes(tone.key)) bad.push(`于月建${tone.key},休囚无气`); + if (overcomes(feishen.element, hushen.element) + && ['strong', 'good'].includes(vitality(feishen.element, monthElement).tone)) { + bad.push('被旺相的飞神克害'); + } + // 「伏神正逢休囚无气」「被日月冲克」「被旺相飞神克害」三条之外, + // 《增删卜易》另列「占卦之日月伏神正逢墓绝」与「伏神直旬空、月破」——正是这三条。 + if (state.isTomb) bad.push('占卦之日月于伏神正逢入墓'); + if (state.isVoid) bad.push(`伏神直${state.status || '旬空'}`); + if (state.isBroken) bad.push('伏神逢月破'); + + // 「这一旬空不空」与「出不出得来」本就是两问,野鹤也分列两处。混在一句里会出现 + // 「旬空而假空……终不得出」这种看着自相矛盾的话,所以两句要分开摆:先讲空, + // 真空就把空本身算作出不来的一条,假空就说清「空不为其患」——出不来是另有原因。 + const kongHead = (() => { + if (!state.isVoid) return ''; + // 真空时 bad 里有「伏神直真空」兜着底,头一句只点个方向,不必把空说三遍。 + if (state.status === '真空') return '伏神旬空而真空,'; + if (state.status === '假空') return '伏神旬空而假空,空不为其患,'; + return '伏神旬空,真假未判,'; + })(); + + if (good.length > 0) { + return { + key: '出得来', + text: `${kongHead}伏神${good.join('、')},出得来,无用亦为有用。` + + (state.status === '假空' ? '不过此是假空,出旬或逢冲之后才见真章。' : ''), + }; + } + if (bad.length > 0) return { key: '出不来', text: `${kongHead}伏神${bad.join('、')},终不得出,虽有如无。` }; + // 这里走不到,也没有走不到的分支可留:旺衰只有旺相休囚死五档,旺相进 good、 + // 休囚死进 bad,两边必有一边非空,「出得来」与「出不来」已穷尽全部情形。 + // 早先还留过一个「无从判」兜底,扫了两千六百八十八个伏神,一次都没走到——是死代码,删。 + /* c8 ignore next */ + throw new Error('出伏判定漏了情形:旺衰本该让 good 或 bad 必有其一'); +} + +/** 用神那一段。候选不止一亲时只各报所在,不替求测者择。circle 由 buildReading 算好传进来。 */ +function useGodText(topic, god, jingfang, movingPositions, calendar, circle, darkPositions, pressedPositions) { + const motion = motionTiers(calendar, darkPositions, pressedPositions); + // 用神自己那一爻的动静与元神忌神仇神同一口径——两处分开写,早先就写岔过一次: + // 那一圈认四档,这里只认动静两档,于是用神自己暗动了或冲散了也看不出来。 + const where = (name) => god.all.filter((line) => line.relative === name) + .map((line) => { + const kind = motion(line.position); + return `${line.label}${kind === '静' ? '' : `(${kind})`}`; + }) + .join('、'); + const head = `所问为${topic.label},${topic.god.reason}`; + + // 一亲都不上卦:传统从本宫首卦取伏神,连带飞神与出不出得来一并断。 + if (!god.picked && god.present.length === 0) { + return `${head}卦中${god.relatives.join('、')}一亲也不见,属用神不上卦。${hiddenText(god, jingfang, calendar)}`; + } + + // 候选不止一亲:婚恋分男女(byGender),本包不认得求测者性别;疾病是病症与医药 + // 两头看。两种情形都只报所在,不硬择其一——择了就等于替人认了性别或认了病势。 + if (god.relatives.length > 1) { + const rows = god.present.map((name) => `${name}见于${where(name)}`).join(';'); + const tail = topic.god.relate && god.present.length === 2 + ? `${topic.god.relate}` + : topic.god.byGender ? '对照自己那一亲取用。' : ''; + return `${head}卦中${rows}。${god.present.length < god.relatives.length ? `${god.absent.join('、')}不上卦。` : ''}${tail}`; + } + + const picked = god.picked; + const parts = [head, `卦中${god.present[0]}见于${where(god.present[0])}。`]; + if (god.all.length > 1) { + parts.push(`两现,按${god.why}取${picked.label}。`); + } + parts.push(circle.sentence); + const relation = elementRelation(jingfang.lines[jingfang.shi - 1].element, picked.element); + parts.push(picked.position === jingfang.shi + ? '用神恰在世爻之上,所求之事就在自己身上。' + : GOD_SHI_TONE[relation]); + const state = voidSentence(voidReading(picked, { ...calendar, movingPositions })); + if (state) parts.push(state); + // 别把局部也叫 god:这个函数的第二个参数就叫 god,同名会撞成重复声明。 + // (与先前 hiddenGod 那次参数名 jingfang 遮蔽同源,是同一类错。) + const pickedGod = sixGods(calendar.dayStem)[picked.position - 1]; + // 爻位用汉字,跟全篇「二爻」「五爻」一致,别在中文里插个「2爻」。 + parts.push(godSentence(pickedGod, picked.label)); + return parts.join(''); +} + +/** + * 动爻变出来的那一爻:回头生克、进退神、化空化墓。 + * + * 变爻只跟本位这一爻生克,这一条有明文。《增删卜易》:「夫變出之爻,能生克沖合本位之動爻, + * 不能生克他爻,而他爻與本位之動爻,亦不能生克變爻。」所以这里只取本卦动爻与变卦同位那一爻, + * 不去跟别爻攀关系,也不去跟世爻应爻攀——那是「他爻」,书上说得很清楚。 + * + * 化空、化墓只作事实报告,**不配吉凶调子**。野鹤讲空讲的是「动爻逢空」那一层, + * 书上并没有「变爻逢空即凶」这样的断语;变爻是不是空破入墓是看得见的事,说出来就是, + * 成不成仍旧归回头生克那一句管。 + * + * @param {number} position 动爻位 + * @param {import('./jingfang.mjs').Jingfang} jingfang 本卦 + * @param {import('./jingfang.mjs').Jingfang} changedJingfang 变卦 + * @param {object} calendar + */ +function transformReading(position, jingfang, changedJingfang, calendar) { + const moving = jingfang.lines[position - 1]; + const ch = changedJingfang.lines[position - 1]; + const relation = transformRelation(moving, ch); + const move = jinTui(moving.branch, ch.branch); + // 变爻在变卦里是静的,不是本卦的动爻。问它逢什么时要把动爻位与动爻五行清空再问, + // 留着就会把「发动」「得动爻生扶」这些救应错记到它头上——变爻不是动爻,别混。 + const v = voidReading(ch, { ...calendar, movingPositions: [], movingElements: [] }); + const marks = []; + if (v.isVoid) marks.push(v.status === '真空' ? '化真空' : v.status === '假空' ? '化假空' : '化空'); + if (v.isTomb) marks.push('化墓'); + const stateText = marks.length === 0 + ? '' + : `变爻${marks.join('又')}——这是这一爻此刻的处境,看得见;成不成仍旧归上头那句话管。`; + return { + position, + label: moving.label, + moving: `${moving.stem}${moving.branch}${moving.element}`, + movingRelative: moving.relative, + changed: `${ch.stem}${ch.branch}${ch.element}`, + changedRelative: ch.relative, + relation: relation.key, + good: relation.good, + jinTui: move ? move.key : null, + marks, + sentence: `${moving.label}${moving.stem}${moving.branch}${moving.element}${moving.relative}动,` + + `变出${ch.stem}${ch.branch}${ch.element}${ch.relative}。${relation.text}` + + (move ? move.text : '') + + stateText, + }; +} + +/** + * 用神周围那一圈:元神、忌神、仇神。 + * + * 《增删卜易》卷之一·用神元神忌神仇神章第九把三个名目连定义带例子都给足了: + * 「元神者,生用神之爻,即为元神。忌神者,克用神之爻也,即为忌神。仇神者,克制元神 + * 不能生用神,反生忌神而克害用神,即为仇神。」 + * 紧接着野鹤交代了看完用神接着看什么——「既得用神,須看旺衰否?有元神動而生扶否? + * 有忌神動而克害否?」——所以三者各报所在、动不动、月建旺衰,正照这两句来。 + * + * 仇神要**另说一句**:它并不直接克用神,是压着元神使元神生不动,反去生忌神,两头帮倒忙。 + * 断语若写成「仇神克用神」就是把它的路数说反了。 + * + * 同章还有一句本包照办:**「勿以仇神即仇人也」**——仇神是五行位置上那一爻,不是卦里那个人。 + * 原书自己就把话说尽了:卦里称作仇人的另有其人,是应爻克世。两者不混。 + * + * 三者都可能不在卦上——六爻只纳八个地支,五行里本就常常不齐。这时照实说「卦中不见」, + * 不从别处借一爻来凑。借了就是给卦外编爻。 + * + * @param {import('./jingfang.mjs').Jingfang} jingfang + * @param {import('./jingfang.mjs').JingfangLine} picked + * @param {object} calendar + */ +/** + * 动静共四档:发动、动而逢日冲、暗动、安静。 + * + * 暗动与冲散分属两章,且一个只管静爻、一个只管动爻:暗动章管静爻逢冲(旺相为暗动、 + * 休囚为日破),动散章管动爻逢冲(冲散)。所以这里各认各的,不把暗动并进动、 + * 也不把冲散并进静。用神自己那一爻与元神忌神仇神那几行用的是同一个口径。 + * + * @param {{ movingPositions: number[] }} calendar + * @param {number[]} darkPositions + * @param {number[]} pressedPositions + */ +function motionTiers(calendar, darkPositions, pressedPositions) { + const moving = new Set(calendar.movingPositions); + const dark = new Set(darkPositions); + const pressed = new Set(pressedPositions); + return (position) => { + if (pressed.has(position)) return '动而逢日冲'; + if (moving.has(position)) return '动'; + return dark.has(position) ? '暗动' : '静'; + }; +} + +function circleReading(jingfang, picked, calendar, darkPositions, pressedPositions) { + const circle = useGodCircle(jingfang, picked); + const motion = motionTiers(calendar, darkPositions, pressedPositions); + const say = (name, element, lines) => (lines.length === 0 + ? `${name}属${element},本卦六爻里没有这一行` + : `${name}属${element},见${lines.map((line) => `${line.label}(${motion(line.position)},于月建${vitality(line.element, calendar.monthElement).key})`).join('、')}`); + const parts = [ + `按《增删卜易·用神元神忌神仇神章》,用神取${picked.label}${picked.element},它周围还有三个位置:`, + `${say('元神', circle.elements.yuan, circle.yuan)},正是生用神的那一行。`, + `${say('忌神', circle.elements.ji, circle.ji)},正是克用神的那一行。`, + `${say('仇神', circle.elements.chou, circle.chou)}。`, + ]; + // 仇神的路数要说准:它不直接克用神,是压着元神、反去生忌神。写反了这一条就把它当忌神说了。 + if (circle.chou.length > 0) { + parts.push('仇神并不直接克用神,它压着元神叫元神生不动,自己又反去生忌神,两头帮倒忙。'); + } + parts.push('这三者是五行上的位置,不是卦里的人——原书紧接着就提醒「勿以仇神即仇人也」,' + + '卦里那个称作仇人的另有其人,是应爻克世,不在这里头。'); + return { + godPosition: picked.position, + god: picked, + yuan: circle.yuan, + ji: circle.ji, + chou: circle.chou, + elements: circle.elements, + sentence: parts.join(''), + }; +} + +/** + * 日辰冲爻那一段。静爻两路出自《增删卜易·暗动章第二十二》,动爻一路出自紧接的 + * 《动散章第二十三》,判语全部逐字引自这两章,一处自造也没有。 + * + * 本章的吉凶两路判的正是元神与忌神——「暗動者有喜有忌」。这一段是上一层用神圈的 + * 正题,所以要紧的话是「哪一爻暗动了、它是不是元神或忌神」。用神定不下来时 + * (伏神在卦外、两亲各看各的)没有圈,喜忌两路就无从落,只报事实,不硬接。 + * + * 一处传入异说照实交代:传入本该章的忌路作「用神休囚無助,若遇忌神克害用神」, + * 未系「暗动」二字;后世解说多作「忌神暗动克害用神」。本包取**带「暗动」**的读法—— + * 上一句喜路明写「得元神暗動以相生」,句式正相对举,且本章题为暗动、 + * 开篇又点明「暗動者有喜有忌」,忌路若不含暗动,这一喜一忌就对不上。 + * 同章还有一句「忌神明動於卦中,得元神暗動而生用神」:忌神按定义是克用神的, + * 生不了用神,这半句自相矛盾,传本与后世多本都照录未改,本包不据它另立一条。 + * + * @param {{ dark: JingfangLine[], dayBroken: JingfangLine[], pressed: JingfangLine[] }} clash + * @param {ReturnType | null} circle + * @param {object} calendar + */ +function dayClashText(clash, circle, calendar) { + const dayName = BRANCHES[calendar.dayBranch]; + const nameOf = (position) => { + if (!circle) return ''; + if (circle.yuan.some((line) => line.position === position)) return '元神'; + if (circle.ji.some((line) => line.position === position)) return '忌神'; + if (circle.chou.some((line) => line.position === position)) return '仇神'; + return ''; + }; + // 身份另起一句说,不逐爻重复「正是元神」。同一行(同一身份)本就同一五行, + // 旺衰必同,所以整行都在其中时可以并成一句,不必一爻一爻报两遍。 + const roleClause = (lines) => { + if (!circle) return ''; + const said = []; + for (const [name, row] of [['元神', circle.yuan], ['忌神', circle.ji], ['仇神', circle.chou]]) { + const hit = lines.filter((line) => row.some((one) => one.position === line.position)); + if (hit.length === 0) continue; + said.push(hit.length === 1 + ? `${hit[0].label}正是${name}那一行` + : `${name}那一行在${hit.map((line) => line.label).join('、')}都占着`); + } + return said.length === 0 ? '' : `${said.join(',')}。`; + }; + const list = (lines) => lines + .map((line) => `${line.label}${line.element}(于月建${vitality(line.element, calendar.monthElement).key})`) + .join('、'); + const opener = (clash.dark.length > 0 || clash.dayBroken.length > 0) + ? `按《增删卜易·暗动章第二十二》「靜爻旺相日辰沖之爲暗動,靜爻休囚日辰沖之爲破」:被${dayName}日冲到的静爻,旺衰分作两路。` + : `按《增删卜易·动散章第二十三》「占以日辰而沖動爻,謂之沖散」:被${dayName}日冲到的,是动爻。`; + const parts = [opener]; + + if (clash.dark.length > 0) { + parts.push(`${list(clash.dark)}${clash.dark.length > 1 ? '皆为暗动' : '为暗动'}——静而不静,今日起暗中起作用。${roleClause(clash.dark)}`); + // 喜忌两路要落到具体爻上才说得出。用神定不下来时没有圈,就只摆定义,不空谈吉凶。 + if (circle) { + const yuanDark = clash.dark.find((line) => circle.yuan.some((one) => one.position === line.position)); + const jiDark = clash.dark.find((line) => circle.ji.some((one) => one.position === line.position)); + const chouDark = clash.dark.find((line) => circle.chou.some((one) => one.position === line.position)); + const godTone = vitality(circle.god.element, calendar.monthElement); + const rests = ['休', '囚', '死'].includes(godTone.key); + if (yuanDark || jiDark) { + const judged = []; + if (yuanDark) { + judged.push(`元神${yuanDark.label}暗动来生用神,正合原书「用神休囚得元神暗動以相生」那句,谓之喜`); + } + if (jiDark) { + judged.push(`忌神${jiDark.label}暗动起来克害用神,即原书「用神休囚無助,若遇忌神克害用神」,谓之忌`); + } + // 原书两路都写在「用神休囚」的前提下。用神不休囚时前提不成立,标出来,不硬套。 + parts.push(`原书紧接着说「暗動者有喜有忌」,判的正是这一圈:${judged.join(';')}。` + + (rests ? '' : `只是这两路都写在「用神休囚」之下,本卦用神${circle.god.label}于月建为${godTone.key},那一层前提并不齐备。`)); + } else if (chouDark) { + // 仇神暗动要说得出「原书没分这一支」,否则前一句点名仇神、后一句说不在两行上,连读像自相矛盾。 + parts.push('暗动章的喜忌两路只分元神与忌神两支,仇神暗动归哪一支,原书未言,这里不替它定。'); + } else { + parts.push('暗动章的喜忌两路判的是元神与忌神,对别的爻暗动只说「有喜有忌」,没再分派吉凶,这里不替它定。'); + } + } + } + + if (clash.dayBroken.length > 0) { + parts.push(`${list(clash.dayBroken)}${clash.dayBroken.length > 1 ? '皆为日破' : '为日破'}——休囚无气而逢日冲,与暗动恰是同一句话的两头。${roleClause(clash.dayBroken)}`); + } + + if (clash.pressed.length > 0) { + const listed = list(clash.pressed); + const strong = clash.pressed.filter((line) => { + const tone = vitality(line.element, calendar.monthElement).tone; + return tone === 'strong' || tone === 'good'; + }); + parts.push(`${listed}${clash.pressed.length > 1 ? '皆逢日冲' : '逢日冲'},谓之冲散。` + + (strong.length > 0 + ? `其中${strong.map((line) => line.label).join('、')}于月建旺相,原书直言「旺相者沖之不散」。` + : '') + + '这一章的结论恰恰是不散:「予屢試之,旺相者沖之不散,有气者沖之不散,休囚者間有沖散,' + + '亦千百中之一二也」,末了归到「神兆機於動,動必有因」。所以此处只报「哪一爻动而逢日冲」' + + '这件事实,不拿它断凶。'); + } + + // 旧说与野鹤自己的驳一并摆上:只引旧说就成了拿一句被作者否掉的话断卦。 + // 这两句驳的是「暗动迟缓」,跟冲散不相干——只有真出了暗动才摆。 + if (clash.dark.length > 0) { + parts.push('「占以暗動福來而不知,禍來而不覺」是旧说,原作者在本章末尾就驳了它:' + + '「吉凶之應於動,有急緩之應,則緩非此論,何當不知不覺,報應亦非緩也。」暗动不必当成迟缓。'); + } + return parts.join(''); +} + +/** + * 《增删卜易·六合章第十九》「爻之合者」那四名,逐名说清这一爻得了什么。 + * + * 「爻之合者,静而逢合,谓之合起;动而逢合,谓之合绊; + * 爻与爻合谓之合好,爻动化合谓之化扶。」 + * + * 四句各自的落点照原书:「爻静或与日月动爻合者,得合而起,即使爻值休囚亦有旺相之意」; + * 「爻动或与日月动爻合者,谓之动逢合而绊住,反不能动之意」;「爻动与动爻相合,乃得他来合我, + * 与我和好相助之意」;「爻动化出之爻回头相合者,谓之化扶,得他扶助之意」。 + * + * **这一段不给吉凶,这是原书自己收的。** 同章三处:「然必用神有气相宜,用若失陷无益」、 + * 「用神受克,六合有何益哉」、末了「宜合吉,不宜合凶」。所以只报关系与名目, + * 吉凶仍旧归用神旺衰那一路,不在这里替它表态。 + * + * 卦级那三法不在这一段:卦逢六合、六冲变六合、六合变六合是整卦结构,由 clashKinds 出段、 + * 卦体上另画三支合弧,两处都已列全,在这里再列一遍就成了第二份要人核对的账。 + * + * @param {import('./jingfang.mjs').ReturnType} combine + * @param {{ dayBranch: number, monthBranch: number }} calendar + * @returns {string} + */ +function heCombineText(combine, calendar) { + const parts = ['按《增删卜易·六合章第十九》「爻之合者,静而逢合,谓之合起;动而逢合,谓之合绊;' + + '爻与爻合谓之合好,爻动化合谓之化扶」:合落在这几爻上,分这么四名。']; + if (combine.rise.length > 0) { + const said = combine.rise + .map((item) => `${item.line.label}${item.line.branch}${item.line.element}合${item.source}${item.branch}`) + .join('、'); + parts.push(`${said}皆为合起——静爻得合而起,原书说「即使爻值休囚亦有旺相之意」,` + + '起得来的是这一爻,不等于这件事就成。'); + } + if (combine.bind.length > 0) { + const said = combine.bind + .map((item) => `${item.line.label}${item.line.branch}${item.line.element}合${item.source}${item.branch}`) + .join('、'); + parts.push(`${said}皆为合绊——动爻被合住,原书说「反不能动之意」,动起来的事被绊在这里。`); + } + if (combine.friendly.length > 0) { + const said = combine.friendly + .map((item) => `${item.lineA.label}${item.lineA.branch}与${item.lineB.label}${item.lineB.branch}`) + .join('、'); + parts.push(`${said}两动爻相合为合好——是他来合我,原书说「与我和好相助之意」。` + + '此处只取两爻皆动的那一路:同章「但有一爻不动,亦不为合」,有一爻静的算不上合。'); + } + if (combine.support.length > 0) { + const said = combine.support + .map((item) => `${item.line.label}${item.line.branch}化出${item.changedLine.branch}回头相合`) + .join('、'); + parts.push(`${said}为化扶——动爻化出去的那一爻回头来合本爻,原书说「得他扶助之意」。`); + } + // 原书把话收在这一句上,且收在吉凶之前。合不是判词,这一句照录,不替它翻成断语。 + parts.push('以上只说合的关系,不据此断吉凶:同章说「然必用神有气相宜,用若失陷无益」,' + + '又说「用神受克,六合有何益哉」,末了一句「宜合吉,不宜合凶」——合吉合凶仍要看用神旺衰。'); + return parts.join(''); +} + +/** + * 《增删卜易·三刑章第二十一》。这一章只有六句话,判语却比六句话还紧,全在最后那半句上。 + * + * 「寅刑巳、巳刑申、子刑卯、卯刑午、丑戌相刑、未辰相刑。又云:辰午酉亥谓之自刑。 + * 夫三刑者,予屡试之,或因用神休囚又兼他爻犯之,刑者则见凶, + * 而独犯三刑得验者少,占过数十年只验得一卦。」 + * + * **所以「犯刑」本身不是判词,这是原书自己说的。** 野鹤试了几十年,单靠犯刑只验中一卦; + * 要见凶还得搭上两条前提:用神休囚,且另有一爻也犯刑。所以这一段把两条前提逐条核出来摆明, + * 成立不成立都照实说,不拿「犯刑」两个字替用神断吉凶——这跟本包在六冲、六合两章上的处置 + * 是同一条线,只是这一条的依据直接来自原书。 + * + * 书上那个卦例照录在下面,寅月庚申日占痘症得风火家人变离卦:月建寅刑五爻巳火子孙, + * 五爻巳又刑申日,两路都落在同一爻上。子孙当春令、旺相得很,原书仍断「后死于寅日寅时」。 + * 可见刑伤得行的不是休囚那一头,旺相的爻照样被刑。 + * + * @param {import('./jingfang.mjs').ReturnType} punish + * @param {object} jingfang 本卦 + * @param {object|null} circle 用神那一圈,用神定不下来时为 null + * @param {{ monthBranch: number, dayBranch: number, monthElement: string }} calendar + * @returns {string} + */ +function punishText(punish, jingfang, circle, calendar) { + const parts = ['按《增删卜易·三刑章第二十一》「寅刑巳、巳刑申、子刑卯、卯刑午、丑戌相刑、未辰相刑。' + + '又云:辰午酉亥谓之自刑」:刑有方向,下面按「谁刑谁」原样摆出来。']; + if (punish.linePairs.length > 0) { + const said = punish.linePairs + .map((pair) => `${pair.from.label}${pair.from.branch}${pair.self ? '自刑' : `刑${pair.to.branch}`}` + + `${pair.to.label}`) + .join('、'); + parts.push(`卦中${said}。`); + } + if (punish.outside.length > 0) { + // 自刑那一支若照非自刑那样拼,会成「月建三爻酉」——读起来像在说月建就是三爻的酉。 + // 明写「自刑」,跟上面卦中那半句一个口径。 + const said = punish.outside + .map((item) => (item.self + ? `${item.source}与${item.line.label}${item.line.branch}自刑` + : `${item.source}${item.from}刑${item.line.branch}`)) + .join('、'); + parts.push(`${said}。`); + } + + // 两条前提,逐条核。用神定不下来时没有圈,第一条就明说缺哪一层,不空谈。 + if (!circle) { + parts.push('原书说「或因用神休囚又兼他爻犯之,刑者则见凶」——这两条前提本卦核不了第一条:' + + '用神定不下来。写下问题再看这一段,或者由着它只是一条关系,不作吉凶。'); + } else { + const godTone = vitality(circle.god.element, calendar.monthElement); + const rests = ['休', '囚', '死'].includes(godTone.key); + const others = punish.hitPositions.filter((position) => position !== circle.god.position); + parts.push(`原书说「或因用神休囚又兼他爻犯之,刑者则见凶」。拿本卦核这两条:` + + `用神在${circle.god.label}${circle.god.element},于月建${godTone.key}` + + `,${rests ? '正合「用神休囚」那条' : '不算休囚,那条不成立'};` + + `另有${others.length > 0 ? `${others.map((position) => jingfang.lines[position - 1].label).join('、')}犯之` : '没有别的爻犯之'},` + + `「又兼他爻犯之」那条${others.length > 0 ? '成立' : '不成立'}。`); + } + // 这一句是原书的收口,也是本段不许由刑断吉凶的凭据,逐字照录。 + parts.push('但原书紧接着自己收了一句:「而独犯三刑得验者少,占过数十年只验得一卦」——' + + '所以这一段只报刑落在哪里、由谁动的手,不据此断吉凶。'); + return parts.join(''); +} + +/** + * 反伏与卦变的断语。判据在 jingfang.fanfuReading 里,两档为什么必须分开、 + * 乾变坤为什么落在卦变那一档,理由都记在那儿。 + * + * **这一层不定吉凶,依据是原书自己给的。** 章末那句把话说死了: + * 「反伏卦用神旺相不變沖克者則反復,事之必成,第恐用神而化回頭之沖克者,卽是卦變大凶之象。」 + * 两条都以用神为轴,所以这里只把两条前提核出来,判词仍旧归用神旺衰那一路—— + * 与六冲、六合、爻之合、爻之刑各章同一条线。 + * + * 章里那十条分占的断语(占功名、占财物、占坟茔宅舍、占天时、占婚姻、占疾病、 + * 占盗贼官非、占出行、占行人、占彼此),本包只接住所问事类对得上的那几条, + * 接不住的明说接不住,不拿别的占法来顶。 + */ + +/** 章里的占法与本包九类事类对得上的几条。key 取 topics.mjs 的事类标识。 */ +const FANFU_BY_TOPIC = Object.freeze({ + career: '占功名者,用爻旺相,遷而又行往他處,去而仍復來', + wealth: '占財物聚散不常,買賣經營興衰往來不定', + property: '占墳墓宅捨,欲遷不遷,或遷之而再遷,或目下就有遷移之事', + marriage: '占婚姻反復難成', + love: '占婚姻反復難成', + health: '占疾病愈而有病', + journey: '占出行,行至中途變反,卽使到彼,一事無成', + dispute: '占盜賊官非,見而又見', +}); + +/** 反伏章开头那三句,判据的出处,每次都照录,免得读者不知这六支是怎么挑的。 */ +const FANFU_ORIGINAL = '卦有卦變,爻有爻變。卦變者內外動而反伏者同一卦也。如乾卦變坤卦。' + + '爻變者內外爻動而反伏者,非同一卦也。如升之觀是也。' + + '又有外卦反伏而內卦不動者,如觀之坤是也。又有內卦反伏而外卦不動者如巽之觀是也'; + +/** 章末那两条前提的原文。 */ +const FANFU_CLOSING = '反伏卦用神旺相不變沖克者則反復,事之必成,' + + '第恐用神而化回頭之沖克者,卽是卦變大凶之象'; + +/** + * @param {ReturnType} fanfu + * @param {string} changedName 变卦卦名 + * @param {{ key: string, label: string }|null} topic + * @param {ReturnType|null} circle + * @param {{ monthElement: string }} calendar + * @param {readonly number[]} movingPositions + * @param {readonly { position: number, relation: string }[]} transforms + */ +function fanfuText(fanfu, changedName, topic, circle, calendar, movingPositions, transforms) { + const parts = []; + + if (fanfu.guaChange) { + parts.push(`按《增删卜易·反伏章第二十五》「${FANFU_ORIGINAL}」:本卦六爻全动,` + + `变出${changedName}——章里「同一卦」四个字指的就是本卦与变卦同为八纯卦、` + + '两两相对(乾坤、坎离、震巽、艮兑)。这一档换过去的纳支并不逐位相冲,' + + '所以与下面那一档不是一回事,本包分成两路报,合在一起的话乾变坤就得被漏掉。'); + } else { + const swaps = []; + if (fanfu.inner) swaps.push(`内卦${fanfu.innerFrom}换成${fanfu.innerTo}`); + if (fanfu.outer) swaps.push(`外卦${fanfu.outerFrom}换成${fanfu.outerTo}`); + parts.push(`按《增删卜易·反伏章第二十五》「${FANFU_ORIGINAL}」:本卦变出${changedName},` + + `其中${swaps.join(',')}。判据是逐位六冲——换过去的那三支与本卦那三支一一相冲,` + + '章里三例(观之坤、巽之观、升之观)换过去的那一组,无一例外都是这样。'); + const say = fanfu.both + ? '內外反伏者,內外不寧之象也' + : (fanfu.inner ? '內卦反伏,內則不安' : '外卦反伏,外則不寧'); + parts.push(`${say}。章里紧接着列了一串对称的说法:「皆主成而敗,敗而成,有而卽無,` + + '無而卽有,得而失,失而得,來而去,去而來,散而聚,聚而散,動而思靜,靜而思動」' + + '——说的是同一件事的两头会翻面,不是判吉凶。'); + if (fanfu.inner !== fanfu.outer) { + parts.push(fanfu.inner + ? '章里另有一条能直接对上号:「占彼此之形勢者,內卦反伏,我亂他定」——内卦反伏,这一路说的是我这一头先乱。' + : '章里另有一条能直接对上号:「占彼此之形勢者……外卦反伏,他亂我定」——外卦反伏,这一路说的是对方那头先乱。'); + } else { + parts.push('章里那条「占彼此之形勢者,內卦反伏,我亂他定,外卦反伏,他亂我定」,' + + '内外都反伏时两句都沾得上,本包不替你择一句。'); + } + } + + const line = topic ? FANFU_BY_TOPIC[topic.key] : null; + if (line) { + parts.push(`所问落在「${topic.label}」,章里正有这一条:${line}。`); + } else if (topic) { + parts.push(`所问落在「${topic.label}」,反伏章这一节没有对得上的占法——` + + '章里那十条各管一桩事,别的占法挪过来顶就是替人选了,故此处不接。'); + } else { + parts.push('没写所问何事,反伏章那十条占法各管一桩事,接不上;写下问题再按事类看这一段。'); + } + + // 章末那两条前提,逐条核。核不了的那一层,明说缺哪一层。 + if (!circle) { + parts.push(`章末收口的是「${FANFU_CLOSING}」——这两条要以用神为轴,` + + '而本卦取不出用神,第一条就核不了。写下问题再看这一段。'); + return parts.join(''); + } + const godTone = vitality(circle.god.element, calendar.monthElement); + const strong = !['休', '囚', '死'].includes(godTone.key); + const godMoving = movingPositions.includes(circle.god.position); + const huiTouKe = transforms.some( + (item) => item.position === circle.god.position && item.relation === '回头克', + ); + const close = `章末收口的是「${FANFU_CLOSING}」。拿本卦核这两条:` + + `用神在${circle.god.label}${circle.god.element},于月建${godTone.key},` + + `「用神旺相」那条${strong ? '成立' : '不成立'}` + + `(${godTone.key}${strong ? '不属休囚' : '正属休囚'})` + + (godMoving + ? `;用神本爻在动,「用神化回头冲克」那条${huiTouKe ? '成立' : '不成立(变出来的那一爻不克本爻)'}。` + : ';用神本爻不在动,谈不上「化」,回头冲克那条不成立。') + + '章里那句「事之必成」只在这两条同时成立时才有;' + + '化回头冲克成立时,章里接着说那是「卦變大凶之象」。' + + '两条都以用神为轴,所以这一段不另下吉凶判词,判词仍归用神旺衰那一路。'; + parts.push(close); + return parts.join(''); +} + +/** + * 《增删卜易·六冲章第二十》「相冲之法有六」逐条对出本卦这一卦实到哪几种。 + * + * 「子午相冲、丑未相冲、寅申相冲、卯酉相冲、辰戌相冲、巳亥相冲。相冲之法有六: + * 日月冲爻者一也,卦逢六冲者二也,六合卦变六冲者三也,冲变六冲者四也, + * 动爻变冲者五也,爻与爻冲者六也。」 + * + * 第一路「日月冲爻」归日辰与月建,日辰那一半已经在暗动章与动散章里逐爻算过(暗动、 + * 日破、冲散),月建那一半是月破,所以这里不重数,只在收尾时指一句路。本函数数的是 + * 剩下五路里本卦实到的那几条。 + * + * 「冲变六冲」(第四路)底本如此;明天机一系作「六冲卦变六冲者,四也」,把「冲」二字 + * 补全成「六冲卦」。两说指同一件事——变出来的那个卦也是六冲卦——本包取补全的写法, + * 底本原样记在这里。 + * + * @param {{ hexagram: object, jf: object, changed: object|null, + * changedJf: object|null, movingPositions: number[] }} input + */ +function clashKinds({ hexagram, jf, changed, changedJf, movingPositions }) { + const here = hexagramClash(hexagram, jf); + const there = changedJf ? hexagramClash(changed, changedJf) : null; + // 卦内任意两爻相冲。标准那三对(初四、二五、三六)单另走 chong/he 两条判语, + // 这里报的是余下那些撞上的零散对——六冲卦那三对不重复报,免得同一件事说两遍。 + const incidental = []; + for (let a = 1; a <= 6; a += 1) { + for (let b = a + 1; b <= 6; b += 1) { + if (branchClash(jf.lines[a - 1].branchIndex) !== jf.lines[b - 1].branchIndex) continue; + if (CLASH_STANDARD_PAIRS.some(([x, y]) => (x === a && y === b) || (x === b && y === a))) continue; + incidental.push({ a, b, lineA: jf.lines[a - 1], lineB: jf.lines[b - 1] }); + } + } + // 动爻变冲:这一爻动起来变出去的那一爻,正好冲它本位那一爻。 + const transformClash = []; + if (changedJf) { + for (const position of movingPositions) { + const line = jf.lines[position - 1]; + const changedLine = changedJf.lines[position - 1]; + if (branchClash(line.branchIndex) === changedLine.branchIndex) { + transformClash.push({ position, line, changedLine }); + } + } + } + return { + chong: here.chong, + he: here.he, + pairs: here.pairs, + changedChong: there?.chong ?? false, + changedHe: there?.he ?? false, + heToChong: Boolean(here.he && there?.chong), + chongToChong: Boolean(here.chong && there?.chong), + transformClash, + incidental, + incidentalOutsideChong: incidental.length > 0 && !here.chong, + hasAny: here.chong || here.he || Boolean(there?.chong) || transformClash.length > 0 || incidental.length > 0, + }; +} + +const CLASH_STANDARD_PAIRS = Object.freeze([[1, 4], [2, 5], [3, 6]]); + +/** + * 六冲那一段。断语逐字引《增删卜易·六冲章第二十》,该给吉凶的地方一律压在用神上。 + * + * 两条本包不替人定的: + * 一是「凡占凶事,宜于冲散;占吉事,则不宜」。所问算吉事还是凶事,是问卦人自己心里的 + * 定位,一句问题里读不出来;本包不替他把事归到哪一头,只把两半都摆上。 + * 二是「近病逢冲即愈,久病逢冲则死」。新病与久病差着一条命,这是个只有问的人知道的事, + * 本包不认病势,所以占病遇六冲时只引这句、不选边。 + * 官讼那一半反倒能接:所问既已认作官讼是非,原书「惟占官非、盗贼、结绝事者宜之」 + * 说的正是这一类,直接引。 + * + * @param {ReturnType} kinds + * @param {ReturnType|null} circle + * @param {{ key: string, label: string }|null} topic + * @param {object} calendar + */ +function clashText(kinds, circle, topic, calendar) { + const parts = []; + if (kinds.chong || kinds.he) { + const which = kinds.chong ? '六冲卦' : '六合卦'; + const spoken = kinds.pairs.map((pair) => `${pair.lower.branch}${pair.upper.branch}`).join('、'); + parts.push(`本卦是${which}。纳甲装出来初四、二五、三六三对,${kinds.chong ? '三对皆冲' : '三对皆合'}(${spoken})。`); + } else { + const near = kinds.pairs.filter((pair) => pair.kind !== null); + parts.push(`本卦既不是六冲卦也不是六合卦:初四、二五、三六三对里` + + (near.length === 0 ? '一对也不冲不合。' : `只有${near.map((pair) => `${pair.lower.branch}${pair.upper.branch}`).join('、')}这一对${near[0].kind}。`)); + } + + const trans = []; + if (kinds.transformClash.length > 0) { + trans.push(`${kinds.transformClash.map((item) => `${item.line.label}化出${item.changedLine.branch}`).join('、')}` + + ',变出去的那一支正好冲本位那一爻(动爻变冲)'); + } + if (kinds.heToChong) { + trans.push('本卦六合、变卦六冲(六合变六冲)'); + } + if (kinds.chongToChong) { + trans.push('本卦六冲、变卦也是六冲(六冲变六冲)'); + } else if (kinds.changedChong && !kinds.heToChong) { + trans.push('变出来的那个卦是六冲卦(卦变六冲,底本作「冲变六冲」)'); + } + if (trans.length > 0) parts.push(`${trans.join(';')}。`); + + if (kinds.incidentalOutsideChong) { + parts.push(`卦里另有${kinds.incidental.map((pair) => `${pair.lineA.label}${pair.lineA.branch}冲${pair.lineB.label}${pair.lineB.branch}`).join('、')}` + + '——这是卦内两爻相冲,不等于本卦就是六冲卦。'); + } + + if (kinds.heToChong) { + parts.push('原书对六合变六冲写得很重:「诸占先合后离、先亲后疏、先浓后淡,始荣终悴,' + + '得而复失,成而后败,事就而又变也。惟占官非、盗贼、结绝事者宜之。」' + + (topic?.key === 'dispute' + ? '所问正落在官讼是非上,末了那半句说的就是这一类。' + : '末了那半句说的是官非、盗贼、结绝事一类;所问不在此,断语不替它改判吉凶。')); + } + if (kinds.chongToChong) { + parts.push('六冲变六冲,原书作「乃内外变动,交相冲击,必主上下不和,至亲反目,彼此怀奸,' + + '始终不就。若用神再受克者,大凶之兆,纵用神旺相,亦不长久」。'); + } + + if (kinds.chong || kinds.heToChong || kinds.chongToChong) { + parts.push('章末把总规矩收在一句上:「冲者,散也。凡占凶事,宜于冲散;占吉事,则不宜。' + + '亦必兼用神而言,用神若旺,虽冲不碍;用神失陷,凶而又凶。」' + + '所问算吉事还是凶事,是你自己的定位,本包不替你归这一头;后半句判得了,按用神说:'); + if (!circle) { + parts.push('用神定不下来,这一层就不接。'); + } else { + const tone = vitality(circle.god.element, calendar.monthElement); + const strong = tone.tone === 'strong' || tone.tone === 'good'; + parts.push(strong + ? `用神${circle.god.label}${circle.god.element}于月建为${tone.key},照「用神若旺,虽冲不碍」,这层冲不碍着它。` + : `用神${circle.god.label}${circle.god.element}于月建为${tone.key},落在失陷那一头,照「用神失陷,凶而又凶」,这层冲对它不是好事。`); + } + } + + if (topic?.key === 'health' && (kinds.chong || kinds.heToChong || kinds.chongToChong)) { + parts.push('占病另有一条不兼用神的:「惟占病,有远近之分,不用用神,近病逢冲即愈,久病逢冲则死。」' + + '新病还是久病只有你清楚,这里只引这句、不替你选边。'); + } + if (kinds.chong) { + parts.push('原书另有一句兜底:「古以六冲卦,诸占不吉。予屡试之,用神失陷,实不为吉;' + + '用若得地,须以用神断之。」所以六冲卦本身不作凶论。'); + } + return parts.join(''); +} + +/** 化爻那一段。变爻只认本位动爻,所以先把这句规矩摆出来,免得看着像要把变爻拿去六爻通算。 */ +function transformText(transforms, circle) { + if (transforms.length === 0) { + return '六爻皆静,无变卦,也就谈不上变出之爻——本卦的格局就此定格,不会中途生变。'; + } + // 回头克那条「原用二神遇之則凶,忌仇二神遇之反吉也」,得先知道回头克落在哪一爻上才说得出。 + // 用神那圈还没算出来(用神不上卦、两亲各看各的)时就只报关系,不接后半句——空口说凶是编的。 + const at = (lines, position) => lines.some((line) => line.position === position); + const rows = transforms.map((item) => { + if (item.relation !== '回头克' || !circle) return item.sentence; + // 原文只交代了两路:用神一路则凶,忌神仇神一路反吉。元神那一路原书未言,就不替它定。 + if (item.position === circle.godPosition) { + return `${item.sentence}这一爻正是用神——照《卜筮正宗》「原用二神遇之則凶」,用神遭回头克是实打实的凶。`; + } + if (at(circle.ji, item.position) || at(circle.chou, item.position)) { + const name = at(circle.ji, item.position) ? '忌神' : '仇神'; + return `${item.sentence}这一爻正落在${name}那一行——照同章「忌仇二神遇之反吉」,回头克打在${name}上,这一卦里反不作凶论。`; + } + if (at(circle.yuan, item.position)) { + return `${item.sentence}这一爻正落在元神那一行。书上回头克只交代用神与忌仇两路,元神遇之如何原书未言,这里不替它定。`; + } + return item.sentence; + }); + return '按《增删卜易》「夫變出之爻,能生克沖合本位之動爻,不能生克他爻」,变爻只与本位动爻相生克,不与他爻相干:' + + rows.join(' '); +} + +/** + * 用神临六神。只交代这件事是什么调子,成不成仍旧只由生克定—— + * 「吉凶全凭五行生克,情态方看六神吉凶」,这句是本包不许越的界。 + */ +function godSentence(god, lineLabel) { + return `用神临${god}(${lineLabel}),${SIX_GOD_MEANING[god].meaning}——这是这件事的调子,` + + '成不成仍只由上面的生克与旺衰定,六神不改吉凶。'; +} + +/** + * 一爻逢空逢破逢入墓,说人话。 + * 野鹤《增删卜易·旬空章》分真假:「旺不爲空,動不爲空,有日建動爻生扶者不爲空」是假空, + * 出旬与冲空之后照旧有力;「月破爲空」「真空卽春土、夏金、秋木、三冬逢火」才是真空, + * 逢值或逢冲之日应事。 + */ +function voidSentence(v) { + const marks = []; + if (v.isVoid) marks.push('旬空'); + if (v.isBroken) marks.push('月破'); + if (v.isTomb) marks.push('入墓'); + if (marks.length === 0) return ''; + if (v.status === '假空') { + return `${marks.join('又')},然${v.rescues.join('、')},是假空:出旬或逢冲之日照旧有力,不是全无指望。`; + } + if (v.status === '真空') { + // 「逢月破」这条在上头的标记里已经点过名了,不再说第二遍。 + const extra = v.empties.filter((one) => !(v.isBroken && one === '逢月破')); + const because = extra.length === 0 ? '' : `,且${extra.join('、')}`; + // 逢值、出旬能救空,逢冲救不了月破——月破章说死了「虽有日辰之生,亦不能生」, + // 冲得越勤它越受伤。所以只在这一卦没逢月破时提「逢冲」。 + const wait = v.isBroken ? '待出月、逢值再论' : '这一旬里做不成,等出旬逢值或逢冲再论'; + return `${marks.join('又')}${because},是真空:${wait}。`; + } + return `${marks.join('又')},暂看不出真假,等出旬或逢冲之日再定。`; +} + +function buildAdvice(verdict) { + const good = verdict.score >= 1; + const bad = verdict.score <= -1; + return { + suitable: good + ? ['主动推进,抓紧时间', '争取外援相助', '落定结果后再推进下一步'] + : bad + ? ['守成,不宜扩张', '先处理内务再对外', '避开正面对抗'] + : ['按原计划等待时机', '先观察再决定进退'], + avoid: good + ? ['反复犹豫、久拖不决', '轻信口头承诺'] + : bad + ? ['正面强争', '额外投入与加码', '在对方主场行事'] + : ['临时变卦、随意更改方向'], + caution: good && bad + ? '' + : good + ? '体卦得势,但用卦耗你:方向可进,力气要省,别一上来就全力押上。' + : bad + ? '体用相制:局面不在你手上,宜守宜退,不宜正面强求。' + : '', + }; +} + +function serialize(hexagram) { + return { + order: hexagram.order, + key: hexagram.key, + name: hexagram.name, + symbol: hexagramSymbol(hexagram.key), + upper: hexagram.upperTrigram, + lower: hexagram.lowerTrigram, + judgment: hexagram.judgment, + tuan: hexagram.tuan, + image: hexagram.image, + element: uniqueElement(hexagram.upperTrigram.element, hexagram.lowerTrigram.element), + }; +} + +function uniqueElement(upper, lower) { + return upper === lower ? upper : `${upper}${lower}`; +} + +/** + * 同一秒内两次同样的取法,卦序与动爻分毫不差,只靠时间戳加那点哈希是分不开的。 + * 触发这个缺陷的那条路——推演动画(要停 CASTING_HOLD_MS 那么多)期间起卦按钮一直可点, + * 双击就发出两次请求——客户端那边已另设了 `casting` 闸堵上。但 id 撞车这件事本身还在, + * 任何两条同 id 的记录进来,删一条就会把另一条一起带走:store.remove 是按 id 过滤的, + * 实测确实会清掉两条。所以种子末尾再掺一个进程内单调递增的计数:同一秒内不会再撞。 + * 不取模:取模会让计数绕回来时重新撞上,而这一秒内本来就起不了那么多卦。 + */ +let idSequence = 0; + +function buildId(hexagram, positions, now) { + const stamp = now.toISOString().replace(/[-:.TZ]/gu, '').slice(0, 14); + idSequence += 1; + const seed = `${hexagram.order}-${positions.join('')}-${stamp}-${idSequence}`; + let hash = 0; + for (let index = 0; index < seed.length; index += 1) { + hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; + } + return `${stamp}-${hash.toString(36).slice(0, 6)}`; +} diff --git a/plugins/weekbin/chinese-divination/miniapp/node/guaqi.mjs b/plugins/weekbin/chinese-divination/miniapp/node/guaqi.mjs new file mode 100644 index 0000000..f603550 --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/node/guaqi.mjs @@ -0,0 +1,95 @@ +// @ts-check + +/** + * 十二辟卦(又作十二月卦、十二消息卦)。 + * + * 传统以十二卦配十二月,令卦与月建直接挂钩:复主子月、临主丑月,直到坤主亥月。 + * 这与本包已有的「月建五行旺相休囚死」是两条不同的路——那条看的是五行在月令中的 + * 强弱,这条看的是卦象本身随节气消长,因此不重复,也不互相替代。 + * + * 「息」与「消」是这一学说全部的关键:阳爻去而阴爻来为消,阴爻去而阳爻来为息。 + * 从子月复卦到巳月乾卦,阳爻自初爻逐次上升,是为息卦(生长);从午月姤卦到亥月 + * 坤卦,阴爻自初爻逐次上升,是为消卦(消退)。因此十二卦全部取「同性爻在下、 + * 异性爻在上」的规整形状,且集中在乾坤二宫。 + * + * 本文件不载经文——《彖传》《大象传》原句都在 tuan.mjs 与 hexagrams.mjs 里, + * 这里只保留「哪个月主哪一卦」这条传统对应关系,以及它的消长含义。 + */ + +/** + * 一位是地支在 BRANCHES 里的下标,与 almanac.mjs 的月支一致。 + * key 是该卦的六位阴阳串,1 阳 0 阴,自下而上——十二辟卦的爻象是固定的, + * 直接按爻象查表,不必由形状反推(试过按「转换点唯一」推,会把屯、师、谦等 + * 下卦两爻为阳、上卦参杂的卦一并算进去,错了五十七处)。 + * phase 是这一月的消长阶段,yangCount 是当月纯阳的爻数。 + * @type {readonly (readonly [number, string, string, '息' | '消', number, string, string])[]} + */ +const MONTH_ORDER = [ + [0, '复', '地雷复', '息', 1, '100000', '一阳来复。冬至之后阳气初生,阳爻生于初爻,故曰复。'], + [1, '临', '地泽临', '息', 2, '110000', '二阳息阴。阳气续长而及于二爻,冬去春临,故曰临。'], + [2, '泰', '地天泰', '息', 3, '111000', '三阳息阴。天地气交,上下相通,三阳开泰,故曰泰。'], + [3, '大壮', '雷天大壮', '息', 4, '111100', '四阳息阴。阳气已过半春,雷动于天,故曰大壮。'], + [4, '夬', '泽天夬', '息', 5, '111110', '五阳息阴。阴气仅余上爻,阳盛而阴决,故曰夬。'], + [5, '乾', '乾为天', '息', 6, '111111', '六阳息阴。纯阳之卦,阳气至此而极,故曰乾。'], + [6, '姤', '天风姤', '消', 1, '011111', '一阴消阳。夏至之后阴气始生于初爻,阳极而阴将起,故曰姤。'], + [7, '遁', '天山遁', '消', 2, '001111', '二阴消阳。阴气渐长,阳气退避,故曰遁。'], + [8, '否', '天地否', '消', 3, '000111', '三阴消阳。天地不交,上下不通,故曰否。'], + [9, '观', '风地观', '消', 4, '000011', '四阴消阳。风行地上,观乎天文以察时变,故曰观。'], + [10, '剥', '山地剥', '消', 5, '000001', '五阴消阳。阴气剥尽在下之阳,万物至此而剥,故曰剥。'], + [11, '坤', '坤为地', '消', 6, '000000', '六阴消阳。纯阴之卦,阴气至此而极,一阳将复,故曰坤。'], +]; + +/** @type {Map} */ +const BY_BRANCH = new Map( + MONTH_ORDER.map(([branch, short, name, phase, yangCount, key, meaning]) => [ + branch, + { branch, short, name, phase, yangCount, key, meaning }, + ]), +); + +/** @type {Map>} */ +const BY_KEY = new Map(MONTH_ORDER.map(([, , , , , key]) => [key, toEntry(key)])); + +/** + * 本月的卦气主卦。 + * + * @param {number} branch 月支下标,与 almanac.mjs 的 BRANCHES 同序 + * @returns {{branch: number, short: string, name: string, phase: '息'|'消', yangCount: number, meaning: string, key: string} | null} + */ +export function monthQi(branch) { + return BY_BRANCH.get(branch) ?? null; +} + +/** + * 本卦在十二消息中的位置。 + * + * 十二辟卦并非另一套吉凶判断,而是给本卦一个「当下走到哪一步」的坐标:本卦若落在 + * 息卦那一段,说明它正合于阳气生长的时势;若落在消卦那一段,则正当阴长阳退。 + * 落在哪一卦,则看它的阴爻位置——消息卦的形状由初爻起算的纯阳爻数决定。 + * + * 不在十二辟卦之列的卦(它们分属八宫其余)不硬套,只说明它不是当令之主。 + * + * @param {string} key 六位阴阳串 + * @returns {{name: string, short: string, phase: '息'|'消', yangCount: number, meaning: string, yang: boolean} | null} + */ +export function hexagramQi(key) { + const entry = BY_KEY.get(key); + if (!entry) return null; + return { + name: entry.name, + short: entry.short, + phase: entry.phase, + yangCount: entry.yangCount, + meaning: entry.meaning, + yang: entry.phase === '息', + }; +} + +/** @param {string} key */ +function toEntry(key) { + const [branch, short, name, phase, yangCount, , meaning] = MONTH_ORDER.find((row) => row[5] === key); + return { branch, short, name, phase, yangCount, key, meaning }; +} + +/** 十二辟卦全表,供卦库与说明文字取用。 */ +export const TWELVE_MESSAGES = Object.freeze([...BY_BRANCH.values()]); diff --git a/plugins/weekbin/chinese-divination/miniapp/node/hexagrams.mjs b/plugins/weekbin/chinese-divination/miniapp/node/hexagrams.mjs new file mode 100644 index 0000000..aa5f850 --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/node/hexagrams.mjs @@ -0,0 +1,259 @@ +// @ts-check + +/** + * 八卦与六十四卦。 + * + * 卦的爻象一律由「上卦 + 下卦」推导,不手写爻字符串:少抄一次就少错一次。 + * 本文件只保留三项必须人工录入的经典文本:卦名、卦辞、象辞。彖传与逐爻爻辞、 + * 小象传分别在 tuan.mjs、yao.mjs、xiang-chuan.mjs 里,理由见各自的说明。 + */ + +import { tuanText } from './tuan.mjs'; + +/** + * 三爻,自下而上,1 为阳、0 为阴。 + * + * direction 是后天八卦方位,categories 是万物类象。两者都是断卦取象的依据: + * 问方位看 direction,问人事物类看 categories。 + */ +const TRIGRAM = { + qian: { + key: 'qian', name: '乾', symbol: '☰', lines: '111', element: '金', nature: '健', image: '天', early: 1, animal: '马', + direction: '西北', family: '父', categories: ['天', '君上', '首领', '父', '金玉', '马', '西北', '刚健', '决断', '公门'], + }, + dui: { + key: 'dui', name: '兑', symbol: '☱', lines: '110', element: '金', nature: '悦', image: '泽', early: 2, animal: '羊', + direction: '正西', family: '少女', categories: ['泽', '口舌', '言语', '饮食', '毁折', '少女', '正西', '喜悦', '巫祝'], + }, + li: { + key: 'li', name: '离', symbol: '☲', lines: '101', element: '火', nature: '丽', image: '火', early: 3, animal: '雉', + direction: '正南', family: '中女', categories: ['火', '日', '明', '文采', '声名', '财货', '中女', '正南', '丽泽', '礼乐'], + }, + zhen: { + key: 'zhen', name: '震', symbol: '☳', lines: '100', element: '木', nature: '动', image: '雷', early: 4, animal: '龙', + direction: '正东', family: '长男', categories: ['雷', '震动', '长子', '车马', '惊扰', '正东', '雷声', '奋发', '足'], + }, + xun: { + key: 'xun', name: '巽', symbol: '☴', lines: '011', element: '木', nature: '入', image: '风', early: 5, animal: '鸡', + direction: '东南', family: '长女', categories: ['风', '长女', '柔顺', '入', '往来', '东南', '虫', '气味', '股'], + }, + kan: { + key: 'kan', name: '坎', symbol: '☵', lines: '010', element: '水', nature: '陷', image: '水', early: 6, animal: '豕', + direction: '正北', family: '中男', categories: ['水', '险陷', '智慧', '暗昧', '盗贼', '劳苦', '正北', '沟渎', '耳'], + }, + gen: { + key: 'gen', name: '艮', symbol: '☶', lines: '001', element: '土', nature: '止', image: '山', early: 7, animal: '狗', + direction: '东北', family: '少男', categories: ['山', '静止', '少男', '门阙', '墙垣', '东北', '医药', '手足', '狗'], + }, + kun: { + key: 'kun', name: '坤', symbol: '☷', lines: '000', element: '土', nature: '顺', image: '地', early: 8, animal: '牛', + direction: '西南', family: '母', categories: ['地', '母', '众', '田土', '房产', '布帛', '西南', '柔顺', '腹', '牛'], + }, +}; + +/** @typedef {typeof TRIGRAM[keyof typeof TRIGRAM]} Trigram */ + +/** @type {Record} */ +export const TRIGRAMS = TRIGRAM; + +/** 先天八卦数,梅花易数起卦取模 8 用。 */ +export const EARLY_NUMBERS = Object.freeze({ + qian: 1, + dui: 2, + li: 3, + zhen: 4, + xun: 5, + kan: 6, + gen: 7, + kun: 8, +}); + +/** + * 六十四卦,通行本次序。卦名书写惯例是「上卦 + 下卦」,例如「水雷屯」为坎上震下。 + * 每行:[上卦, 下卦, 卦名, 卦辞, 象辞] + * @type {readonly (readonly [string, string, string, string, string])[]} + */ +const HEXAGRAMS = [ + ['qian', 'qian', '乾为天', '元亨利贞。', '天行健,君子以自强不息。'], + ['kun', 'kun', '坤为地', '元亨,利牝马之贞。君子有攸往,先迷后得主,利。西南得朋,东北丧朋。安贞,吉。', '地势坤,君子以厚德载物。'], + ['kan', 'zhen', '水雷屯', '元亨利贞。勿用有攸往,利建侯。', '云雷屯,君子以经纶。'], + ['gen', 'kan', '山水蒙', '亨。匪我求童蒙,童蒙求我。初筮告,再三渎,渎则不告。利贞。', '山下出泉,蒙;君子以果行育德。'], + ['kan', 'qian', '水天需', '有孚,光亨,贞吉。利涉大川。', '云上于天,需;君子以饮食宴乐。'], + ['qian', 'kan', '天水讼', '有孚窒惕,中吉终凶。利见大人,不利涉大川。', '天与水违行,讼;君子以作事谋始。'], + ['kun', 'kan', '地水师', '贞,丈人吉,无咎。', '地中有水,师;君子以容民畜众。'], + ['kan', 'kun', '水地比', '吉。原筮,元永贞,无咎。不宁方来,后夫凶。', '地上有水,比;先王以建万国,亲诸侯。'], + ['xun', 'qian', '风天小畜', '亨。密云不雨,自我西郊。', '风行天上,小畜;君子以懿文德。'], + ['qian', 'dui', '天泽履', '履虎尾,不咥人,亨。', '上天下泽,履;君子以辨上下,定民志。'], + ['kun', 'qian', '地天泰', '小往大来,吉亨。', '天地交,泰;后以财成天地之道,辅相天地之宜,以左右民。'], + ['qian', 'kun', '天地否', '否之匪人,不利君子贞,大往小来。', '天地不交,否;君子以俭德辟难,不可荣以禄。'], + ['qian', 'li', '天火同人', '同人于野,亨。利涉大川,利君子贞。', '天与火,同人;君子以类族辨物。'], + ['li', 'qian', '火天大有', '元亨。', '火在天上,大有;君子以遏恶扬善,顺天休命。'], + ['kun', 'gen', '地山谦', '亨,君子有终。', '地中有山,谦;君子以裒多益寡,称物平施。'], + ['zhen', 'kun', '雷地豫', '利建侯行师。', '雷出地奋,豫;先王以作乐崇德,殷荐之上帝,以配祖考。'], + ['dui', 'zhen', '泽雷随', '元亨利贞,无咎。', '泽中有雷,随;君子以向晦入宴息。'], + ['gen', 'xun', '山风蛊', '元亨,利涉大川。先甲三日,后甲三日。', '山下有风,蛊;君子以振民育德。'], + ['kun', 'dui', '地泽临', '元亨,利贞。至于八月有凶。', '泽上有地,临;君子以教思无穷,容保民无疆。'], + ['xun', 'kun', '风地观', '盥而不荐,有孚顒若。', '风行地上,观;先王以省方观民设教。'], + ['li', 'zhen', '火雷噬嗑', '亨。利用狱。', '雷电,噬嗑;先王以明罚敕法。'], + ['gen', 'li', '山火贲', '亨。小利有攸往。', '山下有火,贲;君子以明庶政,无敢折狱。'], + ['gen', 'kun', '山地剥', '不利有攸往。', '山附于地,剥;上以厚下安宅。'], + ['kun', 'zhen', '地雷复', '亨。出入无疾,朋来无咎。反复其道,七日来复,利有攸往。', '雷在地中,复;先王以至日闭关,商旅不行,后不省方。'], + ['qian', 'zhen', '天雷无妄', '元亨利贞。其匪正有眚,不利有攸往。', '天下雷行,物与无妄;先王以茂对时育万物。'], + ['gen', 'qian', '山天大畜', '利贞,不家食吉,利涉大川。', '天在山中,大畜;君子以多识前言往行,以蓄其德。'], + ['gen', 'zhen', '山雷颐', '贞吉。观颐,自求口实。', '山下有雷,颐;君子以慎言语,节饮食。'], + ['dui', 'xun', '泽风大过', '栋桡,利有攸往,亨。', '泽灭木,大过;君子以独立不惧,遁世无闷。'], + ['kan', 'kan', '坎为水', '习坎,有孚,维心亨,行有尚。', '水洊至,习坎;君子以常德行,习教事。'], + ['li', 'li', '离为火', '利贞,亨。畜牝牛,吉。', '明两作,离;大人以继明照于四方。'], + ['dui', 'gen', '泽山咸', '亨,利贞,取女吉。', '山上有泽,咸;君子以虚受人。'], + ['zhen', 'xun', '雷风恒', '亨,无咎,利贞,利有攸往。', '雷风,恒;君子以立不易方。'], + ['qian', 'gen', '天山遁', '亨,小利贞。', '天下有山,遁;君子以远小人,不恶而严。'], + ['zhen', 'qian', '雷天大壮', '利贞。', '雷在天上,大壮;君子以非礼弗履。'], + ['li', 'kun', '火地晋', '康侯用锡马蕃庶,昼日三接。', '明出地上,晋;君子以自昭明德。'], + ['kun', 'li', '地火明夷', '明入地中,明夷。内文明而外柔顺,以蒙大难,文王用之。', '明入地中,明夷;君子以莅众,用晦而明。'], + ['xun', 'li', '风火家人', '利女贞。', '风自火出,家人;君子以言有物而行有恒。'], + ['li', 'dui', '火泽睽', '小事吉。', '上火下泽,睽;君子以同而异。'], + ['kan', 'gen', '水山蹇', '利西南,不利东北。利见大人,贞吉。', '山上有水,蹇;君子以反身修德。'], + ['zhen', 'kan', '雷水解', '利西南。无所往,其来复吉。有攸往,夙吉。', '雷雨作,解;君子以赦过宥罪。'], + ['gen', 'dui', '山泽损', '有孚,元吉,无咎,可贞,利有攸往。', '山下有泽,损;君子以惩忿窒欲。'], + ['xun', 'zhen', '风雷益', '利有攸往,利涉大川。', '风雷,益;君子以见善则迁,有过则改。'], + ['dui', 'qian', '泽天夬', '扬于王庭,孚号有厉。告自邑,不利即戎,利有攸往。', '泽上于天,夬;君子以施禄及下,居德则忌。'], + ['qian', 'xun', '天风姤', '女壮,勿用取女。', '天下有风,姤;后以施命诰四方。'], + ['dui', 'kun', '泽地萃', '亨。王假有庙,利见大人,亨,利贞。用大牲吉,利有攸往。', '泽上于地,萃;君子以除戎器,戒不虞。'], + ['kun', 'xun', '地风升', '元亨。用见大人,勿恤,南征吉。', '地中生木,升;君子以顺德,积小以高大。'], + ['dui', 'kan', '泽水困', '亨,贞,大人吉,无咎,有言不信。', '泽无水,困;君子以致命遂志。'], + ['kan', 'xun', '水风井', '改邑不改井,无丧无得。往来井井。', '木上有水,井;君子以劳民劝相。'], + ['dui', 'li', '泽火革', '巳日乃孚,元亨利贞,悔亡。', '泽中有火,革;君子以治历明时。'], + ['li', 'xun', '火风鼎', '元吉,亨。', '木上有火,鼎;君子以正位凝命。'], + ['zhen', 'zhen', '震为雷', '亨。震来虩虩,笑言哑哑。震惊百里,不丧匕鬯。', '洊雷,震;君子以恐惧修省。'], + ['gen', 'gen', '艮为山', '艮其背,不获其身;行其庭,不见其人,无咎。', '兼山,艮;君子以思不出其位。'], + ['xun', 'gen', '风山渐', '女归吉,利贞。', '山上有木,渐;君子以居贤德善俗。'], + ['zhen', 'dui', '雷泽归妹', '征凶,无攸利。', '泽上有雷,归妹;君子以永终知敝。'], + ['zhen', 'li', '雷火丰', '亨,王假之,勿忧,宜日中。', '雷电皆至,丰;君子以折狱致刑。'], + ['li', 'gen', '火山旅', '小亨,旅贞吉。', '山上有火,旅;君子以明慎用刑而不留狱。'], + ['xun', 'xun', '巽为风', '小亨。利有攸往,利见大人。', '随风,巽;君子以申命行事。'], + ['dui', 'dui', '兑为泽', '亨,利贞。', '丽泽,兑;君子以朋友讲习。'], + ['xun', 'kan', '风水涣', '亨。王假有庙,利涉大川。', '风行水上,涣;先王以享于帝立庙。'], + ['kan', 'dui', '水泽节', '亨。苦节不可贞。', '泽上有水,节;君子以制数度,议德行。'], + ['xun', 'dui', '风泽中孚', '豚鱼吉,利涉大川,利贞。', '泽上有风,中孚;君子以议狱缓死。'], + ['zhen', 'gen', '雷山小过', '亨,利贞,可小事,不可大事。飞鸟遗之音,不宜上宜下,大吉。', '山上有雷,小过;君子以行过乎恭,丧过乎哀,用过乎俭。'], + ['kan', 'li', '水火既济', '亨小,利贞。初吉终乱。', '水在火上,既济;君子以思患而豫防之。'], + ['li', 'kan', '火水未济', '亨。小狐汔济,濡其尾,无攸利。', '火在水上,未济;君子以慎辨物居方。'], +]; + +/** + * @typedef {object} Hexagram + * @property {number} index 0 起的通行本序号(+1 为卦序) + * @property {number} order 卦序,1 至 64 + * @property {string} key 六位爻象串,下爻在前 + * @property {string} name 卦名 + * @property {string} upper 上卦 key + * @property {string} lower 下卦 key + * @property {Trigram} upperTrigram + * @property {Trigram} lowerTrigram + * @property {string} judgment 卦辞 + * @property {string} image 象辞(大象传) + * @property {string} tuan 彖传 + */ + +/** @type {readonly Hexagram[]} */ +export const HEXAGRAM_LIST = Object.freeze( + HEXAGRAMS.map(([upper, lower, name, judgment, image], index) => { + const lowerTrigram = TRIGRAM[lower]; + const upperTrigram = TRIGRAM[upper]; + if (!lowerTrigram || !upperTrigram) throw new Error(`unknown trigram in ${name}`); + return Object.freeze({ + index, + order: index + 1, + key: lowerTrigram.lines + upperTrigram.lines, + name, + upper, + lower, + upperTrigram, + lowerTrigram, + judgment, + image, + tuan: tuanText(index + 1), + }); + }), +); + +/** 爻象串 → 卦,梅花易数算完卦象后查表用。 */ +const BY_KEY = new Map(HEXAGRAM_LIST.map((hexagram) => [hexagram.key, hexagram])); + +/** + * @param {string} key 六位爻象串 + * @returns {Hexagram} + */ +export function hexagramByKey(key) { + const hexagram = BY_KEY.get(key); + if (!hexagram) throw new Error(`no hexagram for lines ${key}`); + return hexagram; +} + +/** + * @param {number} order 卦序 1 至 64 + * @returns {Hexagram} + */ +export function hexagramByOrder(order) { + const hexagram = HEXAGRAM_LIST[order - 1]; + if (!hexagram) throw new Error(`no hexagram for order ${order}`); + return hexagram; +} + +/** + * 由上下卦直接取卦,梅花易数取模起卦时用。 + * @param {number} upperNumber 上卦先天数 1 至 8 + * @param {number} lowerNumber 下卦先天数 1 至 8 + * @returns {Hexagram} + */ +export function hexagramByNumbers(upperNumber, lowerNumber) { + const upper = TRIGRAM[earlyKey(normalizeToEight(upperNumber))]; + const lower = TRIGRAM[earlyKey(normalizeToEight(lowerNumber))]; + return hexagramByKey(lower.lines + upper.lines); +} + +/** @param {number} value @returns {number} 归一到 1 至 8 */ +export function normalizeToEight(value) { + const wrapped = ((value - 1) % 8 + 8) % 8; + return wrapped + 1; +} + +/** @param {number} value @returns {number} 归一到 1 至 6 */ +export function normalizeToSix(value) { + const wrapped = ((value - 1) % 6 + 6) % 6; + return wrapped + 1; +} + +/** @param {number} earlyNumber 先天数 1 至 8 @returns {string} */ +export function earlyKey(earlyNumber) { + const key = Object.keys(EARLY_NUMBERS).find((item) => EARLY_NUMBERS[item] === earlyNumber); + if (!key) throw new Error(`no trigram for early number ${earlyNumber}`); + return key; +} + +/** 互卦:二三四爻为下卦,三四五爻为上卦。 */ +/** @param {Hexagram} hexagram @returns {Hexagram} */ +export function mutualHexagram(hexagram) { + const lines = hexagram.key.split(''); + const lower = [lines[1], lines[2], lines[3]].join(''); + const upper = [lines[2], lines[3], lines[4]].join(''); + return hexagramByKey(lower + upper); +} + +/** 错卦:六爻阴阳互错。 */ +/** @param {Hexagram} hexagram @returns {Hexagram} */ +export function oppositeHexagram(hexagram) { + return hexagramByKey([...hexagram.key].map((line) => (line === '1' ? '0' : '1')).join('')); +} + +/** 综卦:上下颠倒。 */ +/** @param {Hexagram} hexagram @returns {Hexagram} */ +export function invertedHexagram(hexagram) { + return hexagramByKey([...hexagram.key].reverse().join('')); +} + +/** 卦符:自下而上排布,可直接渲染。 */ +/** @param {string} key @returns {string} */ +export function hexagramSymbol(key) { + return [...key].reverse().map((line) => (line === '1' ? '⚊' : '⚋')).join(''); +} diff --git a/plugins/weekbin/chinese-divination/miniapp/node/jingfang.mjs b/plugins/weekbin/chinese-divination/miniapp/node/jingfang.mjs new file mode 100644 index 0000000..82d9485 --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/node/jingfang.mjs @@ -0,0 +1,1550 @@ +// @ts-check + +/** + * 京房象数:八宫、纳甲、六亲、世应。 + * + * 这是与梅花易数并行的另一层。梅花以「动爻所在经卦为体」讲我与事;京房以「本卦 + * 属哪一宫、定第几世」讲六爻各自的身份——哪一爻是我(世),哪一爻是对方(应), + * 每一爻在五行生克下是谁的什么(父母子孙官鬼妻财兄弟)。两套各管一段,不互相替代: + * 梅花说「我和这件事」,京房说「我和这个人、每一方是谁」。 + * + * 出处与底本 + * ────────── + * 八宫卦序与世次出自《京氏易传》(西汉京房)。八纯卦各领七卦,按爻变推出来: + * 初爻变=一世,初二爻变=二世,初三四爻变=三世,初至四爻变=四世, + * 初至五爻变=五世;五世卦第四爻变回本宫为游魂;游魂卦下三爻收回来为归魂。 + * 「归魂」一句最容易记错:游魂的下三爻收回来,净效果等于**从本宫卦只变第五爻**, + * 不是变第四、五爻。本文件据此推导而非抄表——抄表就有抄错的余地。推导时先写成 + * 变第四、五爻,八宫推出的归魂卦整列全错,与传世卦序一撞就查出来了。 + * + * 纳支用后世沿用两千年的纳支歌诀原文: + * 乾金甲子外壬午,坎水戊寅外戊申,艮土丙辰外丙戌,震木庚子外庚午, + * 巽木辛丑外辛未,离火己卯外己酉,坤土乙未外癸丑,兑金丁巳外丁亥。 + * 「内」指下卦三爻,「外」指上卦三爻。乾坤分量最重,各纳两干(乾内甲外壬、 + * 坤内乙外癸);其余六卦内外共用一天干。 + * + * 六亲以**本宫五行为我**:生我者父母,我生者子孙,克我者官鬼,我克者妻财, + * 同我者兄弟。取爻的**地支五行**(纳音不在此列)。 + */ + +import { TRIGRAMS, HEXAGRAM_LIST, hexagramByKey } from './hexagrams.mjs'; +// 本文件已有一个按地支字符取五行的 BRANCH_ELEMENTS;历法那边是按索引排的数组, +// 同名会撞成重复声明,所以这里换个别名,别图省事直接 import 同名常量。 +import { branchClash, muJue, seasonVacuous, xunKong, SIX_HARMONY, BRANCH_ELEMENTS as ELEMENT_BY_BRANCH } from './almanac.mjs'; + +import { BRANCHES } from './almanac.mjs'; + +/** 地支字面 → 序号。旬空月破墓绝都按序号比,序号在这层只此一处取。 */ +const BRANCH_ORDER = BRANCHES; + +const GENERATES = Object.freeze({ 木: '火', 火: '土', 土: '金', 金: '水', 水: '木' }); +const OVERCOMES = Object.freeze({ 木: '土', 土: '水', 水: '火', 火: '金', 金: '木' }); + +function generatesTo(from, to) { + return GENERATES[from] === to; +} + +function overcomesTo(from, to) { + return OVERCOMES[from] === to; +} + +/** 五行全序,定义校验表用它遍历,不另写一份。 */ +const ELEMENTS = Object.freeze(['木', '火', '土', '金', '水']); + +/** + * 六神起例,《卜筮全书·卷之一·启蒙节要·起六神决》原文: + * 「甲乙起青龍,丙丁起朱雀,戊日起勾陳,己日起螣蛇,庚辛起白虎,壬癸起玄武。(俱從下起至上。)」 + * 歌诀定的是**初爻**起哪一神;自初爻往上,六神的先后是青龙、朱雀、勾陈、螣蛇、白虎、玄武, + * 日干只管从哪一神起转,顺序永不改变。存成数据是为了让测试逐字对着底本校。 + */ +export const SIX_GOD_SONG = '甲乙起青龙,丙丁起朱雀,戊日起勾陈,己日起螣蛇,庚辛起白虎,壬癸起玄武。(俱从下起至上。)'; + +/** 六神自初爻向上的固定次序。 */ +export const SIX_GOD_ORDER = Object.freeze(['青龙', '朱雀', '勾陈', '螣蛇', '白虎', '玄武']); + +/** 日干序(甲乙丙丁戊己庚辛壬癸)各从哪一神起转。 */ +const SIX_GOD_FIRST = Object.freeze([0, 0, 1, 1, 2, 3, 4, 4, 5, 5]); + +/** + * 六神的象。 + * + * 螣蛇的五行有异说:或作火,或作阴土。本包取**火**并在此注明,因为《卜筮全书》与 + * 《增删卜易》通行本多把它与朱雀同类;取土则与勾陈同,两者一主虚惊一主迟滞,取错说反。 + * + * 另有一条本包死死守住的原则,出处在野鹤一派:「吉凶全凭五行生克,情态方看六神吉凶。」 + * 六神**不参与生克、不改吉凶**,它只说这件事是什么气氛、什么性质。 + */ +export const SIX_GOD_MEANING = Object.freeze({ + 青龙: Object.freeze({ element: '木', meaning: '喜庆、喜事、贵人、酒色、正直' }), + 朱雀: Object.freeze({ element: '火', meaning: '口舌、文书、消息、是非、诉讼' }), + 勾陈: Object.freeze({ element: '土', meaning: '田土、房产、牵连、迟滞、牢狱' }), + 螣蛇: Object.freeze({ element: '火', meaning: '怪异、虚惊、缠绕、噩梦、欺诈' }), + 白虎: Object.freeze({ element: '金', meaning: '凶险、血光、伤病、丧事、威猛' }), + 玄武: Object.freeze({ element: '水', meaning: '暗昧、盗贼、隐私、暧昧、欺瞒' }), +}); + +/** + * 装卦时排的六神:自初爻向上,一神一爻。 + * 书上的两个乾为天卦例本包的测试逐爻对过:甲子日子水子孙临青龙、戊子日子水子孙临勾陈。 + * @param {number} dayStem 日干序 0–9(甲乙丙丁戊己庚辛壬癸) + * @returns {string[]} 下标 0 为初爻,5 为上爻 + */ +export function sixGods(dayStem) { + const start = SIX_GOD_FIRST[dayStem]; + if (start === undefined) return []; + return SIX_GOD_ORDER.map((_, i) => SIX_GOD_ORDER[(start + i) % 6]); +} + +/** 十二地支五行。地支定这一爻的五行,六亲与一切生克都从这里起。 */ +export const BRANCH_ELEMENTS = Object.freeze({ + 子: '水', 丑: '土', 寅: '木', 卯: '木', 辰: '土', 巳: '火', + 午: '火', 未: '土', 申: '金', 酉: '金', 戌: '土', 亥: '水', +}); + +/** + * 八宫。《京氏易传》原序为乾震坎艮、坤巽离兑:前四为阳宫,后四为阴宫。 + */ +const PALACES = Object.freeze([ + { key: 'qian', name: '乾宫', trigram: '乾', element: '金', polarity: '阳' }, + { key: 'zhen', name: '震宫', trigram: '震', element: '木', polarity: '阳' }, + { key: 'kan', name: '坎宫', trigram: '坎', element: '水', polarity: '阳' }, + { key: 'gen', name: '艮宫', trigram: '艮', element: '土', polarity: '阳' }, + { key: 'kun', name: '坤宫', trigram: '坤', element: '土', polarity: '阴' }, + { key: 'xun', name: '巽宫', trigram: '巽', element: '木', polarity: '阴' }, + { key: 'li', name: '离宫', trigram: '离', element: '火', polarity: '阴' }, + { key: 'dui', name: '兑宫', trigram: '兑', element: '金', polarity: '阴' }, +]); + +/** 世次。flips 相对本宫纯卦要变哪几爻;shi 是世爻位。 */ +const STAGES = Object.freeze([ + { name: '本宫', shi: 6, flips: [], meaning: '八纯卦。六爻同气,事体纯粹,无从变起。' }, + { name: '一世', shi: 1, flips: [1], meaning: '一世变。事在初起,想动而未走远。' }, + { name: '二世', shi: 2, flips: [1, 2], meaning: '二世变。事已成形,行在半途。' }, + { name: '三世', shi: 3, flips: [1, 2, 3], meaning: '三世变。内卦尽变,外象未动。' }, + { name: '四世', shi: 4, flips: [1, 2, 3, 4], meaning: '四世变。将及于外,局面将定未定。' }, + { name: '五世', shi: 5, flips: [1, 2, 3, 4, 5], meaning: '五世变。外部尽变,只余上爻本宫。' }, + { name: '游魂', shi: 4, flips: [1, 2, 3, 5], meaning: '游魂。外已复本而内卦离散,事无定向、往来不定。' }, + { name: '归魂', shi: 3, flips: [5], meaning: '归魂。内卦归于本宫,事有归处、复回本位。' }, +]); + +/** + * 纳支歌诀:内卦三支、外卦三支。乾内甲子外壬午、坤内乙未外癸丑各纳两干, + * 其余六卦内外同干。 + */ +const NAJIA = Object.freeze({ + qian: { inner: { stem: '甲', branches: ['子', '寅', '辰'] }, outer: { stem: '壬', branches: ['午', '申', '戌'] } }, + zhen: { inner: { stem: '庚', branches: ['子', '寅', '辰'] }, outer: { stem: '庚', branches: ['午', '申', '戌'] } }, + kan: { inner: { stem: '戊', branches: ['寅', '辰', '午'] }, outer: { stem: '戊', branches: ['申', '戌', '子'] } }, + gen: { inner: { stem: '丙', branches: ['辰', '午', '申'] }, outer: { stem: '丙', branches: ['戌', '子', '寅'] } }, + kun: { inner: { stem: '乙', branches: ['未', '巳', '卯'] }, outer: { stem: '癸', branches: ['丑', '亥', '酉'] } }, + xun: { inner: { stem: '辛', branches: ['丑', '亥', '酉'] }, outer: { stem: '辛', branches: ['未', '巳', '卯'] } }, + li: { inner: { stem: '己', branches: ['卯', '丑', '亥'] }, outer: { stem: '己', branches: ['酉', '未', '巳'] } }, + dui: { inner: { stem: '丁', branches: ['巳', '卯', '丑'] }, outer: { stem: '丁', branches: ['亥', '酉', '未'] } }, +}); + +/** 爻位标签,自下而上,index 0 是初爻。 */ +const POSITION_LABELS = Object.freeze(['初爻', '二爻', '三爻', '四爻', '五爻', '上爻']); + +const HEXAGRAMS_BY_KEY = new Map(HEXAGRAM_LIST.map((item) => [item.key, item])); + +/** 爻象串翻转若干爻。positions 为 1 起的爻位。 */ +function flip(key, positions) { + const lines = key.split('').map(Number); + for (const position of positions) lines[position - 1] = lines[position - 1] ? 0 : 1; + return lines.join(''); +} + +/** + * 由八纯卦推出六十四卦的宫与世次。 + * 逐宫推完跟卦表核对:撞车说明两个宫算出了同一卦,数量不足说明有卦没被推到, + * 任一情况都在这里抛,不静默出错。 + */ +const BY_HEXAGRAM_KEY = new Map(); +/** 每宫的八卦,按世次先后排好:卦盘上那一列同宫名单就是从这里取的。 */ +const PALACE_ROSTERS = new Map(); +for (const palace of PALACES) { + const base = TRIGRAMS[palace.key].lines.repeat(2); + const roster = []; + for (const stage of STAGES) { + const hexagram = hexagramByKey(flip(base, stage.flips)); + const existing = BY_HEXAGRAM_KEY.get(hexagram.key); + if (existing) { + throw new Error(`八宫推导撞车:${hexagram.name} 同时算进${existing.palace.name}与${palace.name}`); + } + BY_HEXAGRAM_KEY.set(hexagram.key, { palace, stage }); + roster.push(Object.freeze({ + stage: stage.name, + name: hexagram.name, + key: hexagram.key, + // 相对本宫纯卦变过哪几爻。游魂是五世卦第四爻复本来的,第四爻不在其中; + // 归魂只变第五爻。正因为这两个反着来,「游」「归」两个字才不是随口起的。 + flips: Object.freeze([...stage.flips]), + shi: stage.shi, + ying: yingOf(stage.shi), + })); + } + PALACE_ROSTERS.set(palace.key, Object.freeze(roster)); +} +if (BY_HEXAGRAM_KEY.size !== HEXAGRAM_LIST.length) { + throw new Error(`八宫推导只得到 ${BY_HEXAGRAM_KEY.size} 卦,应为 ${HEXAGRAM_LIST.length} 卦`); +} + +/** 以本宫五行为「我」,论爻的五行是什么身份。 */ +function relativeOf(palaceElement, lineElement) { + if (lineElement === palaceElement) return '兄弟'; + if (generatesTo(lineElement, palaceElement)) return '父母'; + if (generatesTo(palaceElement, lineElement)) return '子孙'; + if (overcomesTo(lineElement, palaceElement)) return '官鬼'; + if (overcomesTo(palaceElement, lineElement)) return '妻财'; + return '兄弟'; +} + +/** + * 世应相隔三位,且是配对关系:初应四、二应五、三应六,返过来四应一、五应二、 + * 六应三。八纯卦世在上爻、坎水等五世卦世在五爻,若照字面「世 + 3」会算到第七、 + * 第八爻去,所以要绕回。一世至三世本就在前三爻内,加三不加不减。 + */ +function yingOf(shi) { + return shi <= 3 ? shi + 3 : shi - 3; +} + +/** + * @typedef {object} JingfangLine + * @property {number} position 爻位 1 至 6 + * @property {string} label 初爻…上爻 + * @property {string} stem 天干 + * @property {string} branch 地支 + * @property {number} branchIndex 地支索引 0–11 + * @property {string} element 地支五行 + * @property {string} relative 六亲 + * @property {string} role '世' | '应' | '' + * + * @typedef {object} Jingfang + * @property {string} palaceName 乾宫… + * @property {string} palaceTrigram 本宫纯卦名 + * @property {string} element 宫五行 + * @property {string} polarity 阳宫 / 阴宫 + * @property {string} stage 本宫…归魂 + * @property {number} shi 世爻位 + * @property {number} ying 应爻位(世 + 3) + * @property {string} stageMeaning 本宫与游归的含义 + * @property {JingfangLine[]} lines 六爻,自下而上 + */ + +function palaceEntry(hexagram) { + const found = BY_HEXAGRAM_KEY.get(hexagram.key); + if (!found) throw new Error(`八宫查不到 ${hexagram.name || hexagram.key}`); + return found; +} + +/** + * 给一个卦装上京房的宫、世次、纳甲与六亲。 + * + * @param {{ key: string, name?: string }} hexagram + * @returns {Jingfang} + */ +export function jingfang(hexagram) { + const { palace, stage } = palaceEntry(hexagram); + const record = HEXAGRAMS_BY_KEY.get(hexagram.key); + const lower = NAJIA[record.lower]; + const upper = NAJIA[record.upper]; + const ying = yingOf(stage.shi); + + const lines = POSITION_LABELS.map((label, index) => { + const position = index + 1; + const isInner = position <= 3; + const source = isInner ? lower.inner : upper.outer; + const branch = source.branches[isInner ? position - 1 : position - 4]; + const element = BRANCH_ELEMENTS[branch]; + return Object.freeze({ + position, + label, + stem: source.stem, + branch, + // 旬空、月破、墓绝都按地支序号算,留着它就不用每处再查一次字表。 + branchIndex: BRANCH_ORDER.indexOf(branch), + element, + relative: relativeOf(palace.element, element), + role: position === stage.shi ? '世' : position === ying ? '应' : '', + }); + }); + + return Object.freeze({ + palaceName: palace.name, + palaceTrigram: palace.trigram, + element: palace.element, + polarity: `${palace.polarity}宫`, + stage: stage.name, + shi: stage.shi, + ying, + stageMeaning: stage.meaning, + // 同宫那八卦。卦盘上那一列小卦就是它,每一格标着相对本宫纯卦变过哪几爻。 + roster: PALACE_ROSTERS.get(palace.key), + lines: Object.freeze(lines), + }); +} + +/** + * 同宫那八卦,按世次先后排好。 + * + * 拿本宫纯卦作底,按各世次的变爻推出来,所以这一列与宫、世次是同一套推导出来的, + * 不是另抄的一张表。卦名、变爻、世应都在里面,够卦盘画一整列小卦。 + * + * @param {{ key: string, name?: string }} hexagram + * @returns {ReadonlyArray<{ stage: string, name: string, key: string, + * flips: readonly number[], shi: number, ying: number }>} + */ +export function palaceRoster(hexagram) { + return PALACE_ROSTERS.get(palaceEntry(hexagram).palace.key); +} + +/** + * 查卦时用这个就够:只要宫与世次,不必装六亲。 + * + * @param {{ key: string, name?: string }} hexagram + * @returns {{ palace: typeof PALACES[number], stage: typeof STAGES[number], ying: number }} + */ +export function palaceOf(hexagram) { + const { palace, stage } = palaceEntry(hexagram); + return Object.freeze({ palace, stage, ying: yingOf(stage.shi) }); +} + +/** + * 世爻对世爻之外那一方。 + * + * @param {string} shiElement + * @param {string} yingElement + * @returns {{ key: string, text: string } | null} + */ +export function shiYingRelation(shiElement, yingElement) { + const pairs = [ + [generatesTo(shiElement, yingElement), '生', '世生应', '我这一方主动去就对方,付出在前,谋事多由我推动'], + [generatesTo(yingElement, shiElement), '被生', '应生世', '对方主动向我,机会多从对方来,宜受之而不宜强求'], + [overcomesTo(shiElement, yingElement), '克', '世克应', '我压得住对方,主动权在握,但费力'], + [overcomesTo(yingElement, shiElement), '被克', '应克世', '对方压住我,处境受制,宜守不宜攻'], + [shiElement === yingElement, '比和', '世应比和', '两边同一路人,立场相近,事情好谈但也难分高下'], + ]; + const hit = pairs.find(([matched]) => matched); + return hit ? { key: /** @type {string} */ (hit[1]), text: `${/** @type {string} */ (hit[2])}——${/** @type {string} */ (hit[3])}` } : null; +} + +/** 六亲各管什么事,断语里按所问取用神时用。 */ +export const RELATIVE_MEANING = Object.freeze({ + 父母: '文书契约、长辈、上级、庇护与辛劳', + 兄弟: '同辈、同行、竞争者,也主分夺破耗', + 子孙: '后辈、福佑、解忧,也主医药与消弭', + 妻财: '财物、货物、妻与所求之利', + 官鬼: '官职、名位、夫婿、忧患与阻挠', +}); + +/** + * 取用神:在卦中锁定代表所问之事的那一爻。 + * + * 择爻次序本包从简为两条:**动爻优先,其次取近世爻者**。传世取法更细,两爻俱动时 + * 取旺相者、俱静时取旺相或临世应者,还要看日辰、旬空、墓库——那些本包不具条件, + * 就不硬凑一条像模像样却无法核验的规则。近世取绝对爻位差,不带旺衰,是能核验的那条。 + * + * 用神不上卦(六亲一个也没出现在卦里)时,传统要从本宫首卦取伏神。伏神要连带飞神、 + * 出伏与否一并判,属另一层,本包此处只如实说「不上卦」,不硬编。 + * + * @param {Jingfang} jingfang + * @param {readonly string[]} relatives 用神取哪几个六亲 + * @param {readonly number[]} movingPositions 动爻位 + * @returns {{ + * relatives: readonly string[], + * present: readonly string[], + * absent: readonly string[], + * picked: JingfangLine | null, + * all: readonly JingfangLine[], + * why: string, + * }} + */ +export function pickUseGod(jingfang, relatives, movingPositions = []) { + const moving = new Set(movingPositions); + const all = jingfang.lines.filter((line) => relatives.includes(line.relative)); + const present = relatives.filter((name) => all.some((line) => line.relative === name)); + const absent = relatives.filter((name) => !present.includes(name)); + + if (all.length === 0) { + return Object.freeze({ relatives, present, absent, picked: null, all, why: '不上卦' }); + } + // 候选不止一亲时**不择**:婚恋分男女,本包不认得求测者性别;疾病是病症与医药 + // 两头看,择了其一就等于替人认了性别或认了病势。只报各亲所在,取舍交回断语。 + if (relatives.length > 1) { + return Object.freeze({ relatives, present, absent, picked: null, all, why: '两亲各看各的' }); + } + if (all.length === 1) { + return Object.freeze({ relatives, present, absent, picked: all[0], all, why: '卦中独一' }); + } + + const animated = all.filter((line) => moving.has(line.position)); + const pool = animated.length > 0 ? animated : all; + const why = animated.length > 0 ? '动爻优先' : '近世爻者'; + // 绝对爻位差,不带旺衰判断 + let picked = pool[0]; + for (const line of pool) { + if (Math.abs(line.position - jingfang.shi) < Math.abs(picked.position - jingfang.shi)) { + picked = line; + } + } + return Object.freeze({ relatives, present, absent, picked, all, why }); +} + +/** + * 伏神:用神不上卦时,从本宫首卦借来压在某一爻之下。 + * + * 出处为《增删卜易·飞伏神章第二十八》:「若用神不现,即以日月为用神,倘日月非用神者, + * 则于本宫首卦寻之,因本宫首卦,父子财官六亲俱全之故耳。」位置也随之定死:**本宫首卦 + * 里那一亲在第几爻,就伏在本卦的第几爻之下**;本卦同爻位那一爻压着它,就是飞神。 + * + * 书上的两个例证,本文件的测试逐条对: + * 天风姤(乾宫一世)占妻财——姤卦六爻无寅卯,乾为天二爻是妻财寅木,故寅木伏于 + * 姤卦二爻亥水之下,亥水为飞神;亥水生寅木,是「飞来生伏得长生」,作吉断。 + * 天山遁(乾宫二世)占子孙——遁卦无亥子,乾为天初爻是子水子孙,故子水伏于遁卦 + * 初爻辰土之下,辰土为飞神;辰土克子水,是「飞来克伏遭克害」,伏神受制,作凶推。 + * + * 另有一条《火珠林》更严的版本「本宫财官伏世下方可取,不伏世下则不取」——本包不取: + * 它会把大量正常局面直接判成无用神。 + * + * @param {Jingfang} jingfang + * @param {string} relative 缺的那一亲 + * @returns {{ + * hushen: JingfangLine, + * feishen: JingfangLine, + * palaceName: string, + * ambiguous: boolean, + * } | null} + */ +export function hiddenGod(hexagramJingfang, relative) { + const palace = PALACES.find((item) => item.name === hexagramJingfang.palaceName); + if (!palace) return null; + // 参数不能也叫 jingfang:会把这层遮蔽掉,函数内就再也调不到它。 + const palaceJingfang = jingfang(hexagramByKey(TRIGRAMS[palace.key].lines.repeat(2))); + const candidates = palaceJingfang.lines.filter((line) => line.relative === relative); + if (candidates.length === 0) return null; + const hushen = candidates[0]; + return Object.freeze({ + hushen, + feishen: hexagramJingfang.lines[hushen.position - 1], + palaceName: palace.name, + // 八纯卦里同一亲占两爻的情况(乾宫父母在三、六爻)。测试量过六十四卦全量: + // 缺失六亲的实例与伏神爻一一对应,从没碰上过同亲两爻同时缺失,所以不必另设挑法。 + ambiguous: candidates.length > 1, + }); +} + +/** 飞伏生克,四种关系各有定名,出处同《增删卜易》飞伏神章。 */ +export function flyingRelation(hushen, feishen) { + const from = feishen.element; + const to = hushen.element; + if (from === to) return { key: '比和', text: '飞伏同气,伏神不另得生也另不得泄', good: null }; + if (generatesTo(from, to)) { + return { key: '飞来生伏', text: '飞来生伏得长生:压着它的那一爻反倒生它,所求之事虽不在明面,底下是被养着的', good: true }; + } + if (generatesTo(to, from)) { + return { key: '伏去生飞', text: '伏去生飞是泄气:伏神一味往上供,耗神费力,付出多而收成迟', good: false }; + } + if (overcomesTo(to, from)) { + return { key: '伏来克飞', text: '伏来克飞是出暴:伏神一脚踹开压着它的爻,事情应得突然而急,多为不吉之兆', good: false }; + } + return { key: '飞来克伏', text: '飞来克伏是反伤:飞神死死压住伏神,所求之事受压制,难以出头', good: false }; +} + +/** 任两个五行之间的关系,说人话用。from 生 to 为「生」。 */ +export function elementRelation(from, to) { + if (from === to) return '同气'; + if (generatesTo(from, to)) return '生'; + if (generatesTo(to, from)) return '被生'; + if (overcomesTo(from, to)) return '克'; + if (overcomesTo(to, from)) return '被克'; + return '无涉'; +} + +/** + * 用神、元神、忌神、仇神:用神周围那一圈。 + * + * 出处为《增删卜易》卷之一·用神元神忌神仇神章第九,原文连定义带worked example都给足了: + * 「用神者,即前各类之用神。元神者,生用神之爻,即为元神。忌神者,克用神之爻也,即为忌神。 + * 仇神者,克制元神不能生用神,反生忌神而克害用神,即为仇神。假令金为用神,生金者土也, + * 土为元神;克金者火也,火为忌神;克土生火者木也,木为仇神。余仿此。」 + * + * 三个名目都在本卦六爻里找,不出卦外: + * 元神——生用神那一行的爻;忌神——克用神那一行的爻;仇神——克元神那一行的爻。 + * 仇神的害处是**间接**的,这一点原文讲得很明白:它压着元神使元神生不动用神, + * 自己又反去生忌神,等于两头帮倒忙。所以断语不说「仇神克用神」——它并不直接克。 + * + * 三行必然互异,五行里逐个核过:金木水火土五位用神,元、忌、仇各占一支,十九种组合 + * 没有一次撞行;金用神那组也正好是书上的土元、火忌、木仇。加载时核一遍,核不过就抛。 + * + * 另有一条本包要照办的提醒,原文紧接着就说了:**「勿以仇神即仇人也」**——仇神是五行位置上 + * 的那一爻,不是卦里那个人。原文里真正把人称作仇人的,是应爻克世。两者不是一回事。 + * + * @param {Jingfang} jingfang + * @param {JingfangLine} picked 用神爻 + * @returns {{ yuan: JingfangLine[], ji: JingfangLine[], chou: JingfangLine[], elements: { yuan: string, ji: string, chou: string } }} + */ +export function useGodCircle(jingfang, picked) { + const god = picked.element; + const yuanElement = Object.keys(GENERATES).find((element) => GENERATES[element] === god) ?? ''; + const jiElement = Object.keys(OVERCOMES).find((element) => OVERCOMES[element] === god) ?? ''; + // 仇神克的是元神,不是用神——原文「克土生火者木也」那个「克土」说的是元神那一行。 + const chouElement = Object.keys(OVERCOMES).find((element) => OVERCOMES[element] === yuanElement) ?? ''; + const pick = (element) => jingfang.lines.filter((line) => line.element === element); + return Object.freeze({ + yuan: Object.freeze(pick(yuanElement)), + ji: Object.freeze(pick(jiElement)), + chou: Object.freeze(pick(chouElement)), + elements: Object.freeze({ yuan: yuanElement, ji: jiElement, chou: chouElement }), + }); +} + +// 元、忌、仇三行互异,且与书上的金用神一例对得上。定义跟着代码走,核不过就在这里抛。 +for (const god of ELEMENTS) { + const { elements } = useGodCircle( + { lines: ELEMENTS.map((element, index) => ({ position: index + 1, element })) }, + { element: god }, + ); + if (new Set([elements.yuan, elements.ji, elements.chou]).size !== 3) { + throw new Error(`元忌仇校验不过:${god}用神之下算出元${elements.yuan}忌${elements.ji}仇${elements.chou},五行里这三者应当互异`); + } + // 书上给的例子:金为用神,土为元神,火为忌神,木为仇神 + if (god === '金' && (elements.yuan !== '土' || elements.ji !== '火' || elements.chou !== '木')) { + throw new Error(`元忌仇校验不过:书上说金用则土元火忌木仇,实算出元${elements.yuan}忌${elements.ji}仇${elements.chou}`); + } + // 原文说仇神「反生忌神」,这一条也核上——它替忌神出力,自己却并不生用神 + if (GENERATES[elements.chou] !== elements.ji || GENERATES[elements.chou] === god) { + throw new Error(`元忌仇校验不过:${god}用神之仇神${elements.chou}该反生忌神${elements.ji}且不生用神`); + } +} + +/** + * 变出之爻:动爻动了以后变出来的那一爻,讲的是这一爻「往哪儿去」。 + * + * 回头生与回头克的定名,各有一条可核的原话,两句都只看一个方向——**变爻对本爻**: + * 回头生——「巽木变坎水,谓之化生,水回头以生木也,即以吉断。」 + * 回头克——「震木变乾金,谓之化克,金回头以克木也,即以凶推。」 + * 变爻生本爻是回头生,变爻克本爻是回头克,就这么一条线,两个方向。 + * + * 《卜筮正宗·十八问答第二问》更进一步,把回头克的五种情形逐个点出来: + * 「土爻動而變木、木爻動而變金、金爻動而變火、火爻動而變水、水爻動而變土, + * 此是爻之回頭剋也」 + * 正好是「变爻克本爻」在五行上的全部五个组合,一个不多一个不少。本包拿它当回头克的 + * 定义校验表:HUI_TOU_KE_PAIRS 逐对与 overcomesTo 对撞,少一对多一对都在加载时抛。 + * 同章还有一句要紧的——「凡遇回頭剋者,徹底剋盡,原用二神遇之則凶,忌仇二神遇之反吉也」: + * 回头克是「彻底克尽」,凶不凶还要看它落在哪一亲身上,所以断语里把这半句一并带出。 + * + * 反过来的两个方向书上另有名:变爻生本爻为回头生,本爻生变爻为化泄(气泄给变爻), + * 本爻克变爻为化耗(自出力气还要克下),同行者为化比和。这三个本包**只作事实陈述, + * 不配吉凶调子**——《增删卜易》只对回头生、回头克明说了吉凶,其余三个没有原话可依, + * 硬配就成了编。吉凶那一半仍旧只看回头生与回头克两个字。 + */ + +/** 回头克的五个五行组合,出自《卜筮正宗·十八问答第二问》,用来校验定义而非枚举。 */ +export const HUI_TOU_KE_PAIRS = Object.freeze([ + Object.freeze(['土', '木']), + Object.freeze(['木', '金']), + Object.freeze(['金', '火']), + Object.freeze(['火', '水']), + Object.freeze(['水', '土']), +]); + +// 定义校验:表里每一对都得真的是「变爻克本爻」。反过来,五行上的克关系一共就这五对, +// 这里逐一确认表是全的——将来谁动了 OVERCOMES 而忘了回头克,这里先炸。 +for (const [moving, changed] of HUI_TOU_KE_PAIRS) { + if (!overcomesTo(changed, moving)) { + throw new Error(`回头克校验不过:书上说${moving}爻动变${changed}是回头克,而${changed}并不克${moving}`); + } +} +for (const moving of ELEMENTS) { + for (const changed of ELEMENTS) { + if (overcomesTo(changed, moving) && !HUI_TOU_KE_PAIRS.some(([m, c]) => m === moving && c === changed)) { + throw new Error(`回头克校验不过:${moving}爻动变${changed}属于变爻克本爻,歌诀表里却没有这一对`); + } + } +} + +/** + * 动爻与变爻之间的五行关系,即变爻对本爻是回头生、回头克,还是另外三种不配吉凶的。 + * @param {{ element: string }} moving 本卦动爻 + * @param {{ element: string }} changed 变卦同位那一爻 + * @returns {{ key: string, good: boolean|null, text: string }} + */ +export function transformRelation(moving, changed) { + const from = moving.element; + const to = changed.element; + if (from === to) { + return { key: '化比和', good: null, text: '变爻与本爻同气,是化比和:力量不相增减,事情维持原有格局,不进不退。' }; + } + if (generatesTo(to, from)) { + return { key: '回头生', good: true, text: '变爻回头以生本爻,作吉断:动爻往变爻那一头去,反倒得了生扶,越往后越有转机。' }; + } + if (overcomesTo(to, from)) { + return { + key: '回头克', + good: false, + text: '变爻回头以克本爻,作凶推。《卜筮正宗》说它「彻底克尽」,落在用神身上则凶,落在忌神仇神身上反吉。', + }; + } + if (generatesTo(from, to)) { + return { key: '化泄', good: null, text: '本爻生变爻,是化泄:动爻的力量一路泄进变爻,付出在前,收成在后。' }; + } + return { key: '化耗', good: null, text: '本爻克变爻,是化耗:动爻既出了力又去克下,两头耗着,得来不易。' }; +} + +/** + * 进退神,《增删卜易·进退神章第二十九》歌诀原文: + * 「进神:亥化子,寅化卯,巳化午,申化酉,丑化辰,辰化未,未化戌,戍化丑。 + * 退神:子化亥,卯化寅,午化巳,酉化申,辰化丑,未化辰,戍化未,丑化戍。」 + * + * 底本此处「戍」即「戌」,同一个字的异体;歌诀照录底本,算的时候一律按十二支正字写「戌」。 + * 十六对两两互为反面,且每一对本支同行:水水、木木、火火、金金各一对,剩下四对都在土上 + * ——丑→辰→未→戌→丑这一圈上前后各走一步。土占四对不是笔误,是这一行本来就密。 + * + * 这是固定映射不是形状推导,所以直接查表;表本身在模块加载时逐对核两件事——同行、反向互为 + * 反面,数量也得是十六对。任一条不合就在这里抛,不留一个能算错的表。 + */ +export const JIN_TUI_SONG = '进神:亥化子,寅化卯,巳化午,申化酉,丑化辰,辰化未,未化戌,戍化丑。退神:子化亥,卯化寅,午化巳,酉化申,辰化丑,未化辰,戍化未,丑化戍。'; + +const JIN_TUI = Object.freeze({ + '亥子': '进神', '子亥': '退神', + '寅卯': '进神', '卯寅': '退神', + '巳午': '进神', '午巳': '退神', + '申酉': '进神', '酉申': '退神', + '丑辰': '进神', '辰丑': '退神', + '辰未': '进神', '未辰': '退神', + '未戌': '进神', '戌未': '退神', + '戌丑': '进神', '丑戌': '退神', +}); + +for (const [pair, key] of Object.entries(JIN_TUI)) { + const from = pair[0]; + const to = pair[1]; + if (BRANCH_ELEMENTS[from] !== BRANCH_ELEMENTS[to]) { + throw new Error(`进退神校验不过:歌诀说${from}化${to},而两支不同行`); + } + const back = JIN_TUI[`${to}${from}`]; + if (back !== (key === '进神' ? '退神' : '进神')) { + throw new Error(`进退神校验不过:${from}化${to}为${key},反方向${to}化${from}却是${back ?? '无'}`); + } +} +if (Object.keys(JIN_TUI).length !== 16) { + throw new Error(`进退神只列了 ${Object.keys(JIN_TUI).length} 对,歌诀两首各八字,应为 16 对`); +} + +/** + * 变爻相对本爻是进是退。歌诀里没有的不硬说——不是每一爻都化得成进退。 + * @param {string} fromBranch 本爻地支 + * @param {string} toBranch 变爻地支 + * @returns {{ key: '进神'|'退神', text: string } | null} + */ +export function jinTui(fromBranch, toBranch) { + const key = JIN_TUI[`${fromBranch}${toBranch}`]; + if (!key) return null; + return { + key, + text: key === '进神' + ? `${fromBranch}化${toBranch}是进神:事情往前走一步,力量渐长,宜顺势推进。` + : `${fromBranch}化${toBranch}是退神:事情往回退一步,力量渐消,宜守宜缓。`, + }; +} + +/** + * 纳支里逢不上绝地。绝地是墓的下一支,自墓法:金绝寅、木绝申、水土绝巳、火绝亥。 + * 纳甲里每个五行只占两支(金申酉、木寅卯、水子亥、火巳午、土丑辰未戌),逐支核下来, + * 上列绝支没有一支落在这五行自己占的支里——所以任何一爻、任何一化爻都逢不上绝地, + * 卦体与断语的「绝」小标据此撤掉。这不是漏做,留着就是一条永远不亮的字。 + * 钉在这里是怕日后有人看见「十二长生有绝地」就把它加回来——加回来只会多一个空标记。 + */ +for (const element of ELEMENTS) { + const j = BRANCH_ORDER[(muJue(element).mu + 1) % 12]; + if (BRANCH_ELEMENTS[j] === element) { + throw new Error(`纳支绝地校验不过:${element}的绝地是${j},而纳甲偏偏把${j}也派给了${element},「逢绝」重新成立,墓表与纳支得重核`); + } +} + +/** + * 旬空、月破、入墓,落到一爻上是什么情形;旬空再分真假。 + * + * 出处为《增删卜易·旬空章第二十六》野鹤自道: + * 「旺不爲空,動不爲空,有日建動爻生扶者不爲空,動而化空、伏而旺相皆不爲空。 + * 月破爲空。有卦不動爲空,爻反伏而被克爲空,真空爲空, + * 真空卽春土、夏金、秋木、三冬逢火是真空。」 + * + * 逐条照做,不另立规矩: + * 假空(有救,不作真空论)——旺、动、得日辰或动爻生扶、动而化空、伏而旺相。 + * 真空(真无用)——月破、有气而不动、伏而被克、四季所逢之空元素。 + * + * 一处存疑照实交代:「有卦不動爲空」一句,野鹤原文如此,后世多本作「有氣無動爲空」。 + * 两者差一个「氣」字,意思差得远——前者是说静卦全空,后者只说静而有气者空。 + * 本包取**后者**(有气而不动方论空),理由是它与同段「旺不爲空」不冲突:若静而旺便 + * 算真空,那前一句「旺不为空」就无处容身。此处已在 README 标明是取舍不是定论。 + * + * @param {JingfangLine} line 本卦一爻 + * @param {{ + * monthBranch: number, dayBranch: number, dayIndex: number, + * movingElements: string[], movingPositions: number[], + * isHidden?: boolean, isStruck?: boolean, + * }} calendar + */ +export function voidReading(line, calendar) { + const kong = xunKong(calendar.dayIndex); + const isVoid = kong.voidBranches.includes(line.branchIndex); + const isBroken = branchClash(calendar.monthBranch) === line.branchIndex; + const mj = muJue(line.element); + const isTomb = mj.mu === line.branchIndex; + + // 下面只在「空」这一支上判真假。逢月破而不逢旬空时不算这一支—— + // 但也不能就这么什么也不说:《增删卜易·月破章》「虽有日辰之生,亦不能生」, + // 逢冲只会把它冲得更伤,而同章旬空一篇又把「月破爲空」列在真空那几条里。 + // 所以单逢月破的爻也作真空,只是它的空不是旬空那种「等出旬」,是「待出月」。 + if (!isVoid) { + if (!isBroken) { + return Object.freeze({ + isVoid: false, isBroken, isTomb, + status: null, rescues: Object.freeze([]), empties: Object.freeze([]), + }); + } + return Object.freeze({ + isVoid: false, isBroken, isTomb, + status: '真空', rescues: Object.freeze([]), empties: Object.freeze(['逢月破']), + }); + } + + const dayElement = ELEMENT_BY_BRANCH[calendar.dayBranch]; + const monthElement = ELEMENT_BY_BRANCH[calendar.monthBranch]; + const moving = new Set(calendar.movingPositions); + + /** 有救者不为空。顺序照野鹤原话的次序。 */ + const rescues = []; + const tone = vitality(line.element, monthElement).tone; + if (tone === 'strong' || tone === 'good') { + rescues.push(`旺相(于月建${tone === 'strong' ? '旺' : '相'})`); + } + if (moving.has(line.position)) rescues.push('发动'); + if (generatesTo(dayElement, line.element) || calendar.movingElements.some((e) => generatesTo(e, line.element))) { + rescues.push('得日辰或动爻生扶'); + } + if (calendar.isHidden && (tone === 'strong' || tone === 'good')) rescues.push('伏而旺相'); + + /** 作真空论者。 */ + const empties = []; + if (isBroken) empties.push('逢月破'); + if ((tone === 'strong' || tone === 'good') && !moving.has(line.position)) { + empties.push('有气而不动'); + } + if (calendar.isStruck) empties.push('伏而被克'); + const vacuous = seasonVacuous(calendar.monthBranch); + if (vacuous.vacuousElement === line.element) { + empties.push(`${vacuous.season}令正空${vacuous.vacuousElement}`); + } + + // 有救就不作真空论——这是野鹤的次序:先说不为空,再说什么为空。 + const status = rescues.length > 0 ? '假空' : (empties.length > 0 ? '真空' : '旬空未判'); + return Object.freeze({ + isVoid: true, isBroken, isTomb, + status, rescues: Object.freeze(rescues), empties: Object.freeze(empties), + }); +} + +/** + * 旺相休囚死:月建为令,令生者相、生令者休、克令者囚、令克者死。 + * + * 口诀里的「令」是月建,不是爻——这一层最容易读反。照口诀逐档落: + * 旺=与月建同我;相=月建生爻;休=爻生月建;囚=爻克月建;死=月建克爻。 + * 拿寅卯木令核一遍:木旺、火相、水休、金囚、土死——与《增删卜易·四时旺相章》 + * 逐月所列一致。早先这里把注释写成「克我为囚、我克为死」,那个「我」若指月建, + * 就正好把囚与死掉个个儿;若指爻,又与相、休两档相矛盾。两种读法都跟代码对不上, + * 注释已按口诀改正,代码一直是对的。 + * + * 五档穷尽,且旺相(strong/good)与休囚死(weak/bad)二分干净——暗动与日破 + * 就是拿这条线分的,见 dayClashReading。 + * + * 另有一层本包**未做**:《增删卜易·四时旺相章》在四季土月(辰戌丑未为月建)另有 + * 一条加减,说冲着月建的那一支按休囚论、另支尚有余气不作休囚。那一章在卷内另有章次, + * 且「不作休囚」之后并未交代该落到哪一档,照搬要靠猜,所以此处不并进来。旺相 + * 在四季土月只按本表算,取舍已在 README 标明。 + * + * 放在这层是因为生克的 GENERATES / OVERCOMES 就在这里,挪到别处只会多出第二份口径。 + * @param {string} element 爻或卦的五行 + * @param {string} monthElement 当月月建的五行 + */ +export function vitality(element, monthElement) { + if (element === monthElement) return { key: '旺', tone: 'strong' }; + if (generatesTo(monthElement, element)) return { key: '相', tone: 'good' }; + if (generatesTo(element, monthElement)) return { key: '休', tone: 'weak' }; + if (overcomesTo(element, monthElement)) return { key: '囚', tone: 'bad' }; + return { key: '死', tone: 'bad' }; +} + +/** + * 日辰冲爻,分三路。出处两份,都是逐字引的。 + * + * **静爻那一头是《增删卜易》卷一·暗动章第二十二**: + * 「靜爻旺相日辰沖之爲暗動,靜爻休囚日辰沖之爲破。」 + * 章节号以卷一目录为准作**第二十二**;传本正文在「增删卜易/22」一页题下亦作 + * 「暗動章第二十二」。另有二手站把这章标作「025章」,是按全书篇序排的号, + * 与卷内章次不同,本包不从。 + * + * **动爻那一头是同卷紧接的《动散章第二十三》**: + * 「占以日辰而沖動爻,謂之沖散,又以爻動沖爻,亦能沖散,予屢試之, + * 旺相者沖之不散,有气者沖之不散,休囚者間有沖散,亦千百中之一二也, + * 其故何也?神兆機於動,動必有因。𨿽則今日受制,後逢值日而不散也。」 + * 传本有把这段接在暗动章末的,本包仍按目录作独立的第二十三章。 + * + * 一句话分三路:日辰只冲一支,被冲到的那几爻里,静爻按旺衰分暗动与日破, + * 动爻一律记为冲散。三路互斥,结构上就不会同时出现。 + * + * 四处取舍照实交代: + * + * 一、**动散只管「报」,不断「散」**。这一章的结论恰恰是**不散**:旺相者冲之不散, + * 休囚者也说散是「千百中之一二」——野鹤说他屡试都碰不上几回,末了归到 + * 「神兆機於動,動必有因」。所以本包只把「哪一爻动而逢日冲」这件事实报出来, + * 连带把这句话一起引,不拿它断凶。暗动章那半句「占以暗動福來而不知,禍來而不覺」 + * 是说静爻的,不挪来套在动爻上。 + * + * 二、**「有气者冲之不散」那半句没做**。「有气」在《四时旺相章》里是四季土月的余气 + * 一档,与旺相不同档;原书没有说它落到旺衰哪一档,照搬要靠猜。旺相与休囚两路 + * 分尽已够,此处只按旺相休囚死五档判,取舍已写进 README。 + * + * 三、**月建冲动爻不作冲散**,这一条是明文。同卷日辰章驳别家时写: + * 「他書有云,爻逢月建日沖而不散,是明知當令,不畏日沖」——《易冒·日冲章》说 + * 「如動爻遇日辰相沖,苟非月建,則謂之散」,「苟非月建」四字就是豁免。 + * 所以本包只取日辰这一路冲散,月建那一路只留给既有的月破。 + * + * 四、**「又以爻動沖爻」那半句没做**。他爻发动来冲要看两爻谁强谁弱 + * (《易冒》作「若彼強我弱皆散」),强弱不在本包现有口径里,硬判就是自造一档。 + * 只取日辰冲这一路。 + * + * 另有一处结构事实顺带钉住:日辰所冲之支与被冲之爻只可能同行或相克,六冲六对里 + * 丑未、辰戌同为土不生不克,子午、寅申、卯酉、巳亥四对都是日辰克本爻,没有一对 + * 是日辰生本爻。所以「得日辰生扶」这一路救不了被冲的爻——旬空章那半个救应 + * 对暗动与冲散天然不适用。下方加载时把这一条钉死。 + * + * @param {Jingfang} jingfang + * @param {{ monthBranch: number, dayBranch: number, movingPositions: number[] }} calendar + * @returns {{ dark: JingfangLine[], dayBroken: JingfangLine[], pressed: JingfangLine[] }} + */ +export function dayClashReading(jingfang, calendar) { + const monthElement = ELEMENT_BY_BRANCH[calendar.monthBranch]; + const moving = new Set(calendar.movingPositions); + const clashed = branchClash(calendar.dayBranch); + const dark = []; + const dayBroken = []; + const pressed = []; + for (const line of jingfang.lines) { + if (line.branchIndex !== clashed) continue; + // 动爻逢日冲走冲散那一路,与静爻的两路互斥,所以下面那个 continue 挪到了这里。 + if (moving.has(line.position)) { + pressed.push(line); + continue; + } + const tone = vitality(line.element, monthElement).tone; + (tone === 'strong' || tone === 'good' ? dark : dayBroken).push(line); + } + return Object.freeze({ + dark: Object.freeze(dark), + dayBroken: Object.freeze(dayBroken), + pressed: Object.freeze(pressed), + }); +} + +/** + * 六冲卦与六合卦。判据是纳甲装出来的六支按初四、二五、三六配成三对,三对全冲为六冲卦, + * 三对全合为六合卦。 + * + * 出处:《增删卜易》卷一·六合章第十九与六冲章第二十。 + * 六合章:「卦逢六合者,即如天地否卦內外六爻自相和合是也,不動亦是。」 + * 六冲章:「相冲之法有六……卦逢六冲者,二也。」 + * + * **配对为什么是初四、二五、三六,不是内外卦各自成对。** 这一步容易数错: + * 纳甲装卦时内卦三爻排在初二三、外卦三爻排在四五六,两卦的地支起点错开一位 + * (乾内子寅辰、外午申戌,坤内未巳卯、外丑亥酉),所以内外对应的地支是「隔三位相配」 + * 才撞得上,不是「内外同位相配」。按同位去配,六十四卦里一个六冲卦也找不出来。 + * + * **「三对全冲」其实一组就够。** 世传歌诀说「这三组,只要有一组相冲,其他两组必定相冲, + * 一看就知」。这不是经验,是纳甲定死的:两卦的地支是同一起点的平移,六冲六对把十二支 + * 分成六组互斥的搭档,任一支的冲支唯一,所以三组里撞上一组,另两组必也撞上。下方 + * 加载时把这一条对六十四卦逐个核过。 + * + * 卦内**任意**两爻相冲是另一回事,频次高得多(六十四卦里三十卦都有,六冲卦之外的二十卦 + * 也有),本函数不把它算成六冲卦——六冲卦是三对皆冲的整卦结构,爻与爻冲是零散的事实, + * 两者混为一谈会把三分之一的卦都说成六冲卦。 + * + * @param {object} hexagram + * @param {{ palace: string, stage: string, shi: number, ying: number, element: string, + * lines: JingfangLine[] }} [prepared] 已有京房卦就传进来,省一次重算 + * @returns {{ chong: boolean, he: boolean, pairs: Array<{ lower: JingfangLine, upper: JingfangLine, kind: '冲'|'合' }> }} + */ +export function hexagramClash(hexagram, prepared) { + const jf = prepared ?? jingfang(hexagram); + const pairs = CLASH_PAIR_OFFSETS.map(([lower, upper]) => { + const a = jf.lines[lower - 1]; + const b = jf.lines[upper - 1]; + const kind = branchClash(a.branchIndex) === b.branchIndex + ? '冲' + : SIX_HARMONY.some(([x, y]) => (x === a.branchIndex && y === b.branchIndex) + || (x === b.branchIndex && y === a.branchIndex)) ? '合' : null; + return { lower: a, upper: b, kind }; + }); + const chong = pairs.every((pair) => pair.kind === '冲'); + const he = pairs.every((pair) => pair.kind === '合'); + return Object.freeze({ chong, he, pairs: Object.freeze(pairs) }); +} + +/** + * 地支相刑。出处是《增删卜易》卷一·三刑章第二十一,该章原文只有这六句: + * + * 「寅刑巳、巳刑申、子刑卯、卯刑午、丑戌相刑、未辰相刑。又云:辰午酉亥谓之自刑。」 + * + * **本包照这六条原样排,不照八字那一套。** 子平命理流行的八条是「寅刑巳、巳刑申、申刑寅、 + * 丑刑戌、戌刑未、未刑丑、子刑卯、卯刑子」,与此有两处实质不同: + * 一、增删卜易作「卯刑午」,命理作「卯刑子」; + * 二、增删卜易作「未辰相刑」,命理作「未刑丑、戌刑未」两条。 + * 两套在六十四卦上落出来的卦也不一样:底本这六条碰得出三十七卦,命理那八条碰得出四十八卦。 + * 底本既是本包据以立论的那一部,异说在这里记明,不混着排——两套一起排就成了二十四条, + * 断语里「有几爻犯刑」这句话立刻失去意义。 + * + * **刑是有向的。** 「寅刑巳」说的是寅去刑巳,不是巳去刑寅。方向在六十四卦上确实落出差别: + * 丑刑戌有十二卦,戌刑未只四卦。所以这里存成有序的二元组,按 a 刑 b 逐对判,不做成无向表。 + * 子卯那一对两向都刑(底本作「子刑卯、卯刑午」,命理作「子刑卯、卯刑子」,两向都在), + * 但那是两条分别的话,不是同一条的镜像,所以照样分开存。 + * + * 自刑四支辰午酉亥出自同章的「又云」,不是那六句之一,另存一处。 + */ +const PUNISH_PAIRS = Object.freeze([ + ['寅', '巳'], ['巳', '申'], ['子', '卯'], ['卯', '午'], ['丑', '戌'], ['未', '辰'], +]); + +/** 自刑四支:辰午酉亥再见同支为刑。 */ +const SELF_PUNISH_BRANCHES = Object.freeze(['辰', '午', '酉', '亥']); + +/** a 是否刑 b。取自 {@link PUNISH_PAIRS},有序。 */ +function branchPunishes(a, b) { + return PUNISH_PAIRS.some(([from, to]) => from === a && to === b); +} + +/** 自刑只在本支见本支时成立,别的支之间没有自刑一说。 */ +function branchSelfPunishes(a, b) { + return a === b && SELF_PUNISH_BRANCHES.includes(a); +} + +/** + * 爻之刑。三刑章那句话是这一层唯一的主张,而它自带一个很紧的前提。 + * + * 原文:「夫三刑者,予屡试之,**或因用神休囚又兼他爻犯之**,刑者则见凶, + * 而**独犯三刑得验者少,占过数十年只验得一卦**。」 + * + * 也就是说「犯刑」本身不是判词:野鹤自己试了几十年,单靠犯刑只验中一卦,要见凶还得搭上 + * 用神休囚与另一爻也犯刑两条。所以本函数只报「谁刑了谁」,那两条前提由断语核, + * 吉凶仍旧归用神旺衰——这跟本包在六冲、六合两章上的处置是同一条线。 + * + * 三路都报:爻与爻相刑、动变两爻之外的本支自刑、以及日月与爻相刑。 + * 刑不像冲,方向明确(见 {@link PUNISH_PAIRS} 的注),所以报的时候把「谁刑谁」原样带出去, + * 不折成一句「此爻犯刑」——折了就看不出是谁动的。 + * + * @param {Jingfang} jingfang + * @param {{ monthBranch: number, dayBranch: number }} calendar + * @returns {{ + * linePairs: readonly { from: JingfangLine, to: JingfangLine, self: boolean }[], + * outside: readonly { line: JingfangLine, source: string, from: string, to: string, self: boolean }[], + * hitPositions: readonly number[], hasAny: boolean, + * }} + */ +export function punishReading(jingfang, calendar) { + const linePairs = []; + for (let a = 1; a <= 6; a += 1) { + for (let b = 1; b <= 6; b += 1) { + if (a === b) continue; + const from = jingfang.lines[a - 1]; + const to = jingfang.lines[b - 1]; + if (branchPunishes(from.branch, to.branch)) { + linePairs.push(Object.freeze({ from, to, self: false })); + } else if (branchSelfPunishes(from.branch, to.branch) && a < b) { + // 自刑本就对称,同一对(a 刑 b / b 刑 a)只算一次。 + // 两头都收的话,断语会把「二爻亥自刑四爻」和「四爻亥自刑二爻」并排说一遍, + // 看着像两件事,其实是一对。相刑那六条有方向,不必这样收。 + linePairs.push(Object.freeze({ from, to, self: true })); + } + } + } + // 日月与爻。日辰月建当作一只「爻」来看,方向照旧。 + const outside = []; + for (const source of [ + { name: '日辰', branch: BRANCH_ORDER[calendar.dayBranch] }, + { name: '月建', branch: BRANCH_ORDER[calendar.monthBranch] }, + ]) { + for (const line of jingfang.lines) { + if (branchPunishes(source.branch, line.branch)) { + outside.push(Object.freeze({ line, source: source.name, from: source.branch, to: line.branch, self: false })); + } else if (branchPunishes(line.branch, source.branch)) { + outside.push(Object.freeze({ line, source: source.name, from: line.branch, to: source.branch, self: false })); + } else if (branchSelfPunishes(source.branch, line.branch)) { + outside.push(Object.freeze({ line, source: source.name, from: line.branch, to: source.branch, self: true })); + } + } + } + const hit = new Set(); + for (const item of linePairs) { + hit.add(item.from.position); + hit.add(item.to.position); + } + for (const item of outside) hit.add(item.line.position); + const hitPositions = [...hit].sort((a, b) => a - b); + return Object.freeze({ + linePairs: Object.freeze(linePairs), + outside: Object.freeze(outside), + hitPositions: Object.freeze(hitPositions), + hasAny: linePairs.length > 0 || outside.length > 0, + }); +} + +/** 十二支的六冲配对,子午丑未寅申卯酉辰戌巳亥,两两互冲。 */ +const SIX_CLASH_PAIRS = Object.freeze([ + ['子', '午'], ['丑', '未'], ['寅', '申'], ['卯', '酉'], ['辰', '戌'], ['巳', '亥'], +]); + +/** 冲不到返回空串。 */ +function branchClash6(branch) { + for (const [x, y] of SIX_CLASH_PAIRS) { + if (x === branch) return y; + if (y === branch) return x; + } + return ''; +} + +/** 取内卦(初二三)或外卦(四五六)三爻的纳支,连成三字。 */ +function trigramBranches(jingfang, inner) { + return jingfang.lines.slice(inner ? 0 : 3, inner ? 3 : 6).map((line) => line.branch).join(''); +} + +/** + * 反伏与卦变。反伏章第二十五把两者摆在一起说,但它们各自的条件对不上, + * 所以这里分成两档报,不合成一条——理由见下面两段注。 + * + * **一、反伏:内卦或外卦的纳支被整体换成与之逐位六冲的那一组。** + * + * 章里给的三例都对得上这一条: + * 「外卦反伏而内卦不动者,如观之坤」→ 观→坤 动五、六,只有外纳支换了 + * 「内卦反伏而外卦不动者,如巽之观」→ 巽→观 动二、三,只有内纳支换了 + * 「爻变者内外爻动…如升之观是也」 → 升→观 动二、三、五、六,内外都换了 + * 三例换过去的那一组,无一例外都是本卦那一组的逐位六冲。六十四卦配六十三种 + * 非空动静扫下来,这一档落在 252 种上(内外都反 4、只内反 124、只外反 124)。 + * + * **二、卦变:六爻全动,且本卦与变卦都是八纯卦。** + * + * 章里第一句「卦变者内外动而反伏者同一卦也。如乾卦变坤卦」举的乾变坤, + * 纳支逐位**一个都不冲**(子对未、寅对巳、辰对卯),所以它不属于上面那一档。 + * 它属于的显然是「同一卦」那四个字:乾与坤都是八纯卦,两两相对 + * (乾坤、坎离、震巽、艮兑)。全动之后还能落到另一个八纯卦的只有这八组。 + * 扫描证实:这一档与反伏那一档**交集为零**,所以确实该分开报—— + * 合成一条的话,乾变坤要么被漏掉,要么得为了它把反伏的判据放宽到不再可核。 + * + * @param {Jingfang} jingfang 本卦 + * @param {{ movingPositions: readonly number[] }} calendar + * @param {Jingfang|null} changedJingfang 变卦;六爻皆静时为 null + * @returns {{ + * inner: boolean, outer: boolean, both: boolean, hasAny: boolean, + * guaChange: boolean, allSixMoving: boolean, + * innerFrom: string, innerTo: string, outerFrom: string, outerTo: string, + * kind: '内外' | '内卦' | '外卦' | '卦变' | '', + * }} + */ +export function fanfuReading(jingfang, calendar, changedJingfang) { + const moving = new Set(calendar.movingPositions); + const allSixMoving = jingfang.lines.every((line) => moving.has(line.position)); + // 六爻皆静就没有变卦,没有变卦就没什么伏不伏的——这一支是「压根没得算」。 + // 注意别顺手把 allSixMoving 也当成闸门:反伏只要求内卦或外卦那一组换了, + // 章里「內卦反伏而外卦不動」「外卦反伏而內卦不動」两句说的正是只动一侧。 + if (!changedJingfang) { + return Object.freeze({ + inner: false, outer: false, both: false, hasAny: false, + guaChange: false, allSixMoving, + innerFrom: '', innerTo: '', outerFrom: '', outerTo: '', + kind: '', + }); + } + const innerFrom = trigramBranches(jingfang, true); + const innerTo = trigramBranches(changedJingfang, true); + const outerFrom = trigramBranches(jingfang, false); + const outerTo = trigramBranches(changedJingfang, false); + const inner = innerFrom !== innerTo && innerTo === [...innerFrom].map(branchClash6).join(''); + const outer = outerFrom !== outerTo && outerTo === [...outerFrom].map(branchClash6).join(''); + // 卦变那一档:六爻全动,且本卦与变卦都是八纯卦——也就是两两相对的那四组 + // (乾坤、坎离、震巽、艮兑),书里「同一卦也」四个字指的就是这个。 + // 「全动」这一条不能省:乾只动初、四两爻也能变到巽为风,两头都是八纯卦, + // 可那一爻没动全,谈不上章里说的「內外動」。八纯卦两两互补,全翻才换得到对宫。 + // 后面那半个条件是被前半个蕴含的——八纯卦两两互补,全翻必落到另一个八纯卦, + // 所以只查本卦与只查两头结果一样。留着是为了把书上「同一卦」那句原样写出来。 + const guaChange = allSixMoving + && jingfang.stage === '本宫' && changedJingfang.stage === '本宫'; + const both = inner && outer; + return Object.freeze({ + inner, outer, both, guaChange, allSixMoving, + innerFrom, innerTo, outerFrom, outerTo, + hasAny: inner || outer || guaChange, + kind: guaChange ? '卦变' : both ? '内外' : inner ? '内卦' : outer ? '外卦' : '', + }); +} + +/** 纳甲六爻的配对位:内卦初二三与外卦四五六错开一位,隔三位相配。 */ +const CLASH_PAIR_OFFSETS = Object.freeze([[1, 4], [2, 5], [3, 6]]); + +/** 与之相合的那一支查不到时返回 -1。 */ +function branchHarmony(branch) { + for (const [x, y] of SIX_HARMONY) { + if (x === branch) return y; + if (y === branch) return x; + } + return -1; +} + +/** + * 爻之合:落在单爻上的那三法,与卦级的三法各管一段。 + * + * 出处:《增删卜易》卷一·六合章第十九。该章分两层,先说「合从何来」有六法, + * 再说「这一爻得叫什么」有四名: + * + * 「相合法有六:日月合爻者一也。爻与爻合者二也。爻动化合者三也。 + * 卦逢六合四也。六冲卦变六合卦五也。六合卦变六合卦六也。」 + * 「爻之合者,静而逢合,谓之合起;动而逢合,谓之合绊; + * 爻与爻合谓之合好,爻动化合谓之化扶。」 + * + * 后三法(卦逢六合、六冲变六合、六合变六合)是整卦的结构,由 hexagramClash 判。 + * 这里做的是前三法,逐爻落地,四名由这一爻的动静与合的来路定: + * + * 合起 静爻与日辰或月建相合 ——「爻静或与日月动爻合者,得合而起, + * 即使爻值休囚亦有旺相之意」 + * 合绊 动爻与日辰或月建相合 ——「爻动或与日月动爻合者,谓之动逢合而绊住, + * 反不能动之意」 + * 合好 两爻相合,且两爻皆动 ——「爻动与动爻相合,乃得他来合我, + * 与我和好相助之意」 + * 化扶 动爻化出之爻回头与本爻相合 ——「爻动化出之爻回头相合者,谓之化扶, + * 得他扶助之意」 + * + * **合好那一条有一句明文限定,两爻皆动才算**:「但有一爻不动,亦不为合」。 + * 所以静爻与动爻相合本包不作合好——那一爻不动,恰恰是这一句排除掉的。 + * 六合章后文另有「爻静或与日月动爻合者」一句,标点两读:读作「与日月、动爻合者」 + * 则静爻也能与动爻成合,这与上句正面冲突。本包从可核的一读, + * 冲突那一支不取,在此记明取舍而不是含糊过去。 + * + * **合好不算卦逢六合那三对。** 初四、二五、三六那三对相合是整卦的六合结构, + * 由 hexagramClash 判并画在卦体边上;这里两爻相合只取其余配对,免得一处事实 + * 在卦体与断语里各记一次账。 + * + * **本函数不定吉凶。** 同章把话收得很紧:「然必用神有气相宜,用若失陷无益」, + * 又说「用神受克,六合有何益哉」,末尾一句「宜合吉,不宜合凶」。合是关系不是判词, + * 吉凶仍归用神旺衰,所以这里只报出关系与名目。 + * + * @param {Jingfang} jingfang 本卦 + * @param {{ monthBranch: number, dayBranch: number, movingPositions: number[] }} calendar + * @param {Jingfang} [changedJingfang] 变卦;没有动爻时可略 + * @returns {{ + * rise: readonly object[], bind: readonly object[], friendly: readonly object[], + * support: readonly object[], hitPositions: readonly number[], hasAny: boolean, + * }} + */ +export function heCombineReading(jingfang, calendar, changedJingfang) { + const moving = new Set(calendar.movingPositions); + // 日月同支时(寅月寅日之类)只算一路。六合是十二支上的两两配对,一支的合支唯一, + // 日辰与月建既然是同一支,合上它的判据就是同一条,报两次是同一件事数了两遍。 + const calendars = calendar.dayBranch === calendar.monthBranch + ? [{ name: '日辰', branchIndex: calendar.dayBranch }] + : [ + { name: '日辰', branchIndex: calendar.dayBranch }, + { name: '月建', branchIndex: calendar.monthBranch }, + ]; + const rise = []; + const bind = []; + const friendly = []; + const support = []; + + for (const line of jingfang.lines) { + for (const source of calendars) { + if (branchHarmony(line.branchIndex) !== source.branchIndex) continue; + // 动静分两头:静爻得合而起,动爻得合而绊。名目不同,在此分派。 + (moving.has(line.position) ? bind : rise).push(Object.freeze({ + line, + source: source.name, + branch: BRANCH_ORDER[source.branchIndex], + })); + } + } + + for (let a = 1; a <= 6; a += 1) { + for (let b = a + 1; b <= 6; b += 1) { + if (!moving.has(a) || !moving.has(b)) continue; + const lineA = jingfang.lines[a - 1]; + const lineB = jingfang.lines[b - 1]; + if (branchHarmony(lineA.branchIndex) !== lineB.branchIndex) continue; + // 初四、二五、三六那三对是整卦六合,不在这里再记一次。 + if (CLASH_PAIR_OFFSETS.some(([x, y]) => (x === a && y === b) || (x === b && y === a))) continue; + friendly.push(Object.freeze({ lineA, lineB })); + } + } + + if (changedJingfang) { + for (const position of calendar.movingPositions) { + const line = jingfang.lines[position - 1]; + const changedLine = changedJingfang.lines[position - 1]; + if (branchHarmony(line.branchIndex) !== changedLine.branchIndex) continue; + support.push(Object.freeze({ line, changedLine })); + } + } + + const hit = new Set(); + for (const item of rise) hit.add(item.line.position); + for (const item of bind) hit.add(item.line.position); + for (const item of friendly) { + hit.add(item.lineA.position); + hit.add(item.lineB.position); + } + for (const item of support) hit.add(item.line.position); + const hitPositions = [...hit].sort((a, b) => a - b); + return Object.freeze({ + rise: Object.freeze(rise), + bind: Object.freeze(bind), + friendly: Object.freeze(friendly), + support: Object.freeze(support), + hitPositions: Object.freeze(hitPositions), + hasAny: rise.length > 0 || bind.length > 0 || friendly.length > 0 || support.length > 0, + }); +} + +// 六冲卦十个、六合卦八个,是传世名单里人人能背下来的两组卦;数目或名单对不上就是装卦错了。 +// 另有一处结构事实顺带钉住:三对里只要有一组相冲(相合),另两组必也相冲(相合)。所以六十四卦 +// 只有三种样子——三对皆冲、三对皆合、三对皆不相干,没有「只撞一对」的。客户端那层冲合连线正是 +// 照这条画的:要么画满三条,要么一条不画。 +// 三条都在下面核,任一条不合就在这里抛,不留一个能算错的判定。 +{ + const chongNames = []; + const heNames = []; + // 三对就是初四、二五、三六,这个数目是定义的一部分,不能从表里推: + // 从表里推的话表里少配一组,数目跟着少一个,下面那两条「三对皆撞」就跟着松了。 + const pairCount = 3; + for (const hexagram of HEXAGRAM_LIST) { + const clash = hexagramClash(hexagram); + if (clash.chong) chongNames.push(hexagram.name); + if (clash.he) heNames.push(hexagram.name); + const chongPairs = clash.pairs.filter((pair) => pair.kind === '冲').length; + const hePairs = clash.pairs.filter((pair) => pair.kind === '合').length; + if ((chongPairs > 0) !== clash.chong) { + throw new Error(`六冲校验不过:${hexagram.name}只有${chongPairs}对冲,却判成非六冲——` + + '「一组冲则三组皆冲」这条结构事实不成立,配对位或纳甲表有问题'); + } + if ((hePairs > 0) !== clash.he) { + throw new Error(`六合校验不过:${hexagram.name}只有${hePairs}对合,却判成非六合——` + + '「一组合则三组皆合」这条结构事实不成立,配对位或六合表有问题'); + } + // 上两条只钉住「有没有」,这里再钉住「是不是三对」:判成六冲六合的必须三对全撞。 + if (clash.chong && chongPairs !== pairCount) { + throw new Error(`六冲校验不过:${hexagram.name}判成六冲,撞上的却只有${chongPairs}对,` + + `不是三对全撞。配对位或纳甲表有问题,连线会画出半截的卦体`); + } + if (clash.he && hePairs !== pairCount) { + throw new Error(`六合校验不过:${hexagram.name}判成六合,撞上的却只有${hePairs}对,` + + `不是三对全撞。配对位或六合表有问题,连线会画出半截的卦体`); + } + } + if (chongNames.length !== 10) { + throw new Error(`六冲卦校验不过:传世名单十个(八纯卦加天雷无妄、雷天大壮),实算出${chongNames.length}个:${chongNames.join('、')}`); + } + if (heNames.length !== 8) { + throw new Error(`六合卦校验不过:传世名单八个(泰否豫贲复困旅节),实算出${heNames.length}个:${heNames.join('、')}`); + } +} + +// 日辰所冲之支永不可能生被冲的那一爻。上面第三条取舍整个建立在这句上,所以在这里钉死。 +for (let branch = 0; branch < 12; branch += 1) { + const clashed = branchClash(branch); + if (generatesTo(ELEMENT_BY_BRANCH[branch], ELEMENT_BY_BRANCH[clashed])) { + throw new Error(`日辰生扶校验不过:日支${BRANCH_ORDER[branch]}能生${BRANCH_ORDER[clashed]},` + + '则被日辰冲的静爻还能得日辰生扶,「暗动不靠日辰生扶」这条取舍得重核'); + } +} + +// 三刑章那六条与自刑四支,落在这六十四卦的纳甲上是什么样子,在这里钉死。 +// 要钉的是三件能查的事:每一条都碰得出卦(碰不出的条目等于写错了字)、六条合起来碰出 +// 三十七卦(数目错一条就露)、以及自刑里辰一支在六十四卦上一次都不出现。 +{ + const SELF_TOTAL = 4; + // 一、辰只装在内卦三爻(乾内子寅辰、坎内寅辰午、艮内辰午申),一卦只有一个下卦, + // 所以一卦里最多一个辰,自刑辰在六十四卦上一次也碰不出来。底本没有这一条, + // 是纳甲装出来的结构事实——写代码的人若以为自刑四支都能碰到,就得在这里撞一下。 + let selfChen = 0; + for (const hexagram of HEXAGRAM_LIST) { + const branches = jingfang(hexagram).lines.map((line) => line.branch); + const chen = branches.filter((branch) => branch === '辰').length; + if (chen > 1) { + throw new Error(`纳甲校验不过:${hexagram.name}里出现了${chen}个辰,` + + '一卦只有一个下卦,辰只装在内卦三爻,碰不出两个——纳甲表有问题'); + } + } + for (const hexagram of HEXAGRAM_LIST) { + const branches = jingfang(hexagram).lines.map((line) => line.branch); + for (let a = 0; a < 6; a += 1) { + for (let b = 0; b < 6; b += 1) { + if (a === b) continue; + if (branchSelfPunishes(branches[a], branches[b]) && branches[a] === '辰') selfChen += 1; + } + } + } + if (selfChen !== 0) { + throw new Error(`自刑校验不过:辰自刑在六十四卦上碰出了${selfChen}次,` + + '辰只装在内卦三爻,一卦最多一个,碰不出自刑辰——纳甲表或自刑四支有问题'); + } + // 二、那六条每一条都得碰得出卦。碰不出就是写错了字,刑这一路会静悄悄少掉一条。 + const pairHits = new Map(PUNISH_PAIRS.map(([from, to]) => [`${from}刑${to}`, 0])); + let hexWithPunish = 0; + for (const hexagram of HEXAGRAM_LIST) { + const jf = jingfang(hexagram); + let hit = false; + for (let a = 1; a <= 6 && !hit; a += 1) { + for (let b = 1; b <= 6; b += 1) { + if (a === b) continue; + const key = `${jf.lines[a - 1].branch}刑${jf.lines[b - 1].branch}`; + if (!pairHits.has(key)) continue; + pairHits.set(key, pairHits.get(key) + 1); + hit = true; + } + } + if (hit) hexWithPunish += 1; + } + for (const [key, count] of pairHits) { + if (count === 0) { + throw new Error(`三刑校验不过:${key}在六十四卦上一次也碰不出,` + + '这一条多半是写错了字,刑这一路会静悄悄少掉一条'); + } + } + // 六条合起来碰出二十八卦。加上自刑那三支能碰着的(辰一支永远碰不着)才是三十七卦—— + // 两个数别混,早先就把这两个数记串过一次。 + if (hexWithPunish !== 28) { + throw new Error(`三刑校验不过:底本那六条在六十四卦上碰出${hexWithPunish}卦,应为二十八卦——` + + '改了刑的条目或纳甲表,这里立刻响'); + } + // 三、刑有向这件事本身查不出来(表是有序二元组,judge 天然按序判), + // 真正要防的是「顺手把底本那六条补成命理那八条」。所以直接数条目:底本是六条。 + if (PUNISH_PAIRS.length !== 6) { + throw new Error(`三刑校验不过:底本那六条现在是${PUNISH_PAIRS.length}条。` + + '命理那八条另存一处可以,别混进来——两套一起排,「有几爻犯刑」这句话就没意义了'); + } + void SELF_TOTAL; +} + + +// 反伏与卦变那一层的地基。两档各自的事实都在这里钉死,改了判据立刻响, +// 免得日后有人把「反伏」放宽成「内外纳支换了就算」而不自知——那会把乾变坤 +// 混进来,而它明明属于「同一卦」那一档。 +{ + // 一、卦变那档:六爻全动之后还能落到八纯卦的,只有八组两两相对。 + let guaChange = 0; + for (const hexagram of HEXAGRAM_LIST) { + const jingfangValue = jingfang(hexagram); + const allSix = [1, 2, 3, 4, 5, 6]; + const changedKey = flip(hexagram.key, allSix); + const changedHexagram = HEXAGRAMS_BY_KEY.get(changedKey); + const hit = fanfuReading(jingfangValue, { movingPositions: allSix }, + jingfang(changedHexagram)); + if (!hit.allSixMoving) { + throw new Error(`反伏校验不过:${hexagram.name}六爻全动却没认成全动`); + } + if (hit.guaChange) guaChange += 1; + } + if (guaChange !== 8) { + throw new Error(`反伏校验不过:六爻全动能落到八纯卦的现在是${guaChange}组,应为八组` + + '(乾坤、坎离、震巽、艮兑)。改了「本宫」的判法,这里立刻响'); + } + + // 二、反伏那档:六十四卦配六十三种非空动静共 4032 种,并集 252 种 + // (内外都反 4、只内反 124、只外反 124)。数目不是从判据推的,是从卦表数出来的。 + // inner / outer 按并集计(内外都反的那几种两头都算),所以各是 128 而不是 124; + // 124 是互斥口径。两处报数对不上号最容易看糊涂,故在此写明。 + let inner = 0; + let outer = 0; + let both = 0; + let union = 0; + let overlapWithGuaChange = 0; + for (const hexagram of HEXAGRAM_LIST) { + const jingfangValue = jingfang(hexagram); + for (let mask = 1; mask < 64; mask += 1) { + const movingPositions = []; + for (let i = 0; i < 6; i += 1) if (mask & (1 << i)) movingPositions.push(i + 1); + const changedHexagram = HEXAGRAMS_BY_KEY.get(flip(hexagram.key, movingPositions)); + const hit = fanfuReading(jingfangValue, { movingPositions }, jingfang(changedHexagram)); + if (hit.inner) inner += 1; + if (hit.outer) outer += 1; + if (hit.both) both += 1; + if (hit.hasAny) union += 1; + if (hit.guaChange && (hit.inner || hit.outer)) overlapWithGuaChange += 1; + } + } + if (inner !== 128 || outer !== 128 || both !== 4 || union !== 260) { + throw new Error(`反伏校验不过:内反${inner}、外反${outer}、内外都反${both}、反伏或卦变共${union}种,` + + '应为 128、128、4、260(252 反伏 + 8 卦变)。改了纳甲表或逐位六冲的判定,这里立刻响'); + } + // 三、两档必须不相交。乾变坤不在逐位六冲那一档里——它换过去的那一组一支都不冲, + // 靠的是「同一卦」。要是哪天把判据放宽到两者有交,这里会响。 + if (overlapWithGuaChange !== 0) { + throw new Error(`反伏校验不过:卦变与反伏那一档出现了${overlapWithGuaChange}处重叠,` + + '两档原本互不相交,重叠说明「反伏」的判据被放宽了'); + } + // 四、六爻皆静时没有变卦,反伏与卦变都该是零——不是「算出来没有」,是压根没得算。 + const still = jingfang(HEXAGRAM_LIST[0]); + const quiet = fanfuReading(still, { movingPositions: [] }, null); + if (quiet.hasAny || quiet.kind !== '') { + throw new Error('反伏校验不过:六爻皆静却报出了反伏或卦变'); + } +} + + +// 爻之合那一层的地基。六合是十二支上的两两配对,这个函数把「四名」的判定整个架在上面: +// 配对若不严格互斥,「与谁合」就有两个答案,同一爻会落进两路,卦体上的小标也要打架。 +// 所以三件事在加载时钉死:表把十二支盖满、每一支的合支唯一、合是双向的。 +{ + const seen = new Set(); + for (let branch = 0; branch < 12; branch += 1) { + const partner = branchHarmony(branch); + if (partner < 0) { + throw new Error(`六合表校验不过:${BRANCH_ORDER[branch]}没有配到合支,表少了一组`); + } + if (seen.has(branch)) { + throw new Error(`六合表校验不过:${BRANCH_ORDER[branch]}被配了两次,表里有重复`); + } + seen.add(branch); + if (branchHarmony(partner) !== branch) { + throw new Error(`六合表校验不过:${BRANCH_ORDER[branch]}与${BRANCH_ORDER[partner]}相合,` + + `但${BRANCH_ORDER[partner]}的合支不是${BRANCH_ORDER[branch]}——合必须双向`); + } + } + // 配对既已互斥且双向,一个日辰支与一个月建支若不同支,不会有哪一支同时合于两者—— + // 否则同一爻会同时落进日辰那一路和月建那一路,断语里就成了同一件事说两遍。 + for (let day = 0; day < 12; day += 1) { + for (let month = 0; month < 12; month += 1) { + if (day === month) continue; + for (let branch = 0; branch < 12; branch += 1) { + if (branchHarmony(day) === branch && branchHarmony(month) === branch) { + throw new Error(`六合表校验不过:${BRANCH_ORDER[branch]}同时合于日支${BRANCH_ORDER[day]}` + + `与月支${BRANCH_ORDER[month]},则「与谁合」有两个答案,四名里该落哪一路就定了`); + } + } + } + } + // 四路各自的成立条件,逐条对着结果核。这一段扫的是全部六十四卦配十二日辰配十二种动静, + // 只核「有没有」没有意义——四路里有三路本来就跟日辰无关——所以核的是每一路凭什么算数。 + for (const hexagram of HEXAGRAM_LIST) { + const jf = jingfang(hexagram); + for (let day = 0; day < 12; day += 1) { + for (let month = 0; month < 12; month += 1) { + for (let mask = 0; mask < 64; mask += 1) { + const moving = []; + for (let position = 1; position <= 6; position += 1) { + if (mask & (1 << (position - 1))) moving.push(position); + } + const got = heCombineReading(jf, { monthBranch: month, dayBranch: day, movingPositions: moving }); + const isMoving = (position) => moving.includes(position); + // 合起:静爻,且与日辰或月建那一支相合 + for (const item of got.rise) { + if (isMoving(item.line.position)) { + throw new Error(`逢合校验不过:${hexagram.name}的${item.line.label}是动爻,却落进合起` + + '——合起只管静爻,动爻逢日月那一路叫合绊'); + } + const partner = branchHarmony(item.line.branchIndex); + if (partner !== day && partner !== month) { + throw new Error(`逢合校验不过:${hexagram.name}的${item.line.label}报了合起,` + + `却既不合日支${BRANCH_ORDER[day]}也不合月支${BRANCH_ORDER[month]}`); + } + } + // 合绊:动爻,且与日辰或月建那一支相合 + for (const item of got.bind) { + if (!isMoving(item.line.position)) { + throw new Error(`逢合校验不过:${hexagram.name}的${item.line.label}是静爻,却落进合绊` + + '——合绊只管动爻,静爻逢日月那一路叫合起'); + } + const partner = branchHarmony(item.line.branchIndex); + if (partner !== day && partner !== month) { + throw new Error(`逢合校验不过:${hexagram.name}的${item.line.label}报了合绊,` + + `却既不合日支${BRANCH_ORDER[day]}也不合月支${BRANCH_ORDER[month]}`); + } + } + // 合好:两支相合,且两爻皆动,且不撞整卦六合那三对 + for (const item of got.friendly) { + if (!isMoving(item.lineA.position) || !isMoving(item.lineB.position)) { + throw new Error(`逢合校验不过:${hexagram.name}的${item.lineA.label}与${item.lineB.label}报了合好,` + + '其中有静爻——「但有一爻不动,亦不为合」'); + } + if (branchHarmony(item.lineA.branchIndex) !== item.lineB.branchIndex) { + throw new Error(`逢合校验不过:${hexagram.name}的${item.lineA.label}与${item.lineB.label}报了合好,` + + '两支却并不相合'); + } + if (CLASH_PAIR_OFFSETS.some(([x, y]) => (x === item.lineA.position && y === item.lineB.position) + || (x === item.lineB.position && y === item.lineA.position))) { + throw new Error(`逢合校验不过:${hexagram.name}的${item.lineA.label}与${item.lineB.label}是` + + '初四二五三六那一对,报了合好——那一对属整卦六合,不在这里再记一次'); + } + } + // 化扶:本爻在动,且化出之爻回头与本爻相合 + for (const item of got.support) { + if (!isMoving(item.line.position)) { + throw new Error(`逢合校验不过:${hexagram.name}的${item.line.label}报了化扶,本爻却不是动爻`); + } + if (branchHarmony(item.line.branchIndex) !== item.changedLine.branchIndex) { + throw new Error(`逢合校验不过:${hexagram.name}的${item.line.label}报了化扶,` + + `变出的${item.changedLine.branch}却与本爻${item.line.branch}不相合`); + } + } + // 逢合爻位就是四路合起来的那几处,不多不少 + const union = new Set(); + for (const item of got.rise) union.add(item.line.position); + for (const item of got.bind) union.add(item.line.position); + for (const item of got.friendly) { + union.add(item.lineA.position); + union.add(item.lineB.position); + } + for (const item of got.support) union.add(item.line.position); + if (union.size !== got.hitPositions.length + || [...union].some((position) => !got.hitPositions.includes(position))) { + throw new Error(`逢合校验不过:${hexagram.name}(日支${BRANCH_ORDER[day]}、月支${BRANCH_ORDER[month]}、` + + `动爻${moving.join(',') || '无'})卦体上标的逢合爻与四路实际报出的对不上`); + } + if (got.hasAny !== got.hitPositions.length > 0) { + throw new Error(`逢合校验不过:${hexagram.name}的 hasAny 与实际命中的爻位对不上`); + } + } + } + } + } +} + +/** + * 旺衰逐月表,直接抄《增删卜易·四时旺相章》,拿来给 vitality 钉桩。 + * + * 八个月建各配一句原话: + * 「正月、二月木为旺,火为相,其余金、水、土俱为休囚」 + * 「四月、五月火旺土相,其余俱作休囚」 + * 「七月、八月金旺生水,水为相,其余俱作休囚」 + * 「十月、十一月水生木,木为相,其余俱作休囚」 + * + * 只钉这八个月。辰戌丑未四个月建不在表内:同章对四季另有一条加减,冲着月建的那支 + * 按休囚论、另支尚有余气不作休囚,四支不再一视同仁。照那条办要补一档没有原话可依的 + * 细分,本包未做(见 vitality 注释),所以这四个月建不进校验,免得拿一张不完整的表 + * 去核一个更宽的算法。 + */ +const FOUR_SEASONS_TONE = [ + { branch: 2, 木: '旺', 火: '相', 土: '死', 金: '囚', 水: '休' }, + { branch: 3, 木: '旺', 火: '相', 土: '死', 金: '囚', 水: '休' }, + { branch: 5, 木: '休', 火: '旺', 土: '相', 金: '死', 水: '囚' }, + { branch: 6, 木: '休', 火: '旺', 土: '相', 金: '死', 水: '囚' }, + { branch: 8, 木: '死', 火: '囚', 土: '休', 金: '旺', 水: '相' }, + { branch: 9, 木: '死', 火: '囚', 土: '休', 金: '旺', 水: '相' }, + { branch: 11, 木: '相', 火: '死', 土: '囚', 金: '休', 水: '旺' }, + { branch: 0, 木: '相', 火: '死', 土: '囚', 金: '休', 水: '旺' }, +]; +for (const row of FOUR_SEASONS_TONE) { + for (const element of ELEMENTS) { + const got = vitality(element, ELEMENT_BY_BRANCH[row.branch]).key; + if (got !== row[element]) { + throw new Error(`旺衰校验不过:《四时旺相章》${BRANCH_ORDER[row.branch]}月${element}爻作${row[element]},` + + `实算出${got}。囚与死最易掉个个儿——令克者死、克令者囚,念反了就会错掉这两档`); + } + } +} + diff --git a/plugins/weekbin/chinese-divination/miniapp/node/mcp/divination-http.mjs b/plugins/weekbin/chinese-divination/miniapp/node/mcp/divination-http.mjs new file mode 100644 index 0000000..cdefb34 --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/node/mcp/divination-http.mjs @@ -0,0 +1,562 @@ +// @ts-check + +/** + * MCP(streamable-http)端点:让 Agent 在对话里主动起卦。 + * + * 为什么在这里接模型:这个包自己不调任何模型、不出站。Agent 本身就是模型, + * 由它在对话中调用本端点,再用自己的语言把卦象讲给用户听——密钥、计费、上下文 + * 全都留在会话里,本包只负责「算得对」和「讲得准」。 + */ + +import { + buildReading, + castByCoins, + castByNumbers, + castByTime, + castDaily, + tossCoins, +} from '../divination.mjs'; +import { HEXAGRAM_LIST, hexagramSymbol, invertedHexagram, mutualHexagram, oppositeHexagram } from '../hexagrams.mjs'; +import { almanac } from '../almanac.mjs'; +import { jingfang } from '../jingfang.mjs'; +import { TOPIC_KEYS } from '../topics.mjs'; + +const SERVER_INFO = Object.freeze({ name: 'chinese-divination', version: '1.1.0' }); + +const READ_ANNOTATIONS = Object.freeze({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +}); + +/** + * @param {string} name + * @param {string} title + * @param {string} description + * @param {object} inputSchema + */ +function tool(name, title, description, inputSchema) { + return { name, title, description, inputSchema, annotations: READ_ANNOTATIONS }; +} + +const QUESTION_PROPERTY = Object.freeze({ + question: { + type: 'string', + maxLength: 120, + description: + '所问何事,原话即可。写了它,应期与取象才有着落——不写则一律退回用卦本分。', + }, + topic: { + type: 'string', + enum: [...TOPIC_KEYS], + description: + '所问何事归哪一类,由你来定——你读过用户原话,比在本工具里拿关键词撞「他对我还有没有真心」' + + '这类问法可靠。财运=钱与买卖、事业功名=工作前程与升迁、感情=恋爱与暧昧、婚恋=结婚与配偶、' + + '疾病=病症与身体、学业文书=考试与证照、房产车契=房舍车契与搬迁、官讼是非=官司与纠纷、' + + '出行寻物=远行与丢东西。拿不准就省略,省略后本工具按关键词认,认不出就按用卦算。' + + '**只影响应期、用神与取象,不改变卦体吉凶**——同一个卦,问财与问婚,凶不会翻面。', + }, +}); + +const TOOLS = Object.freeze([ + tool( + 'divination_cast', + '起卦并解读', + '按梅花易数起一卦并返回完整解读:卦名、上下卦、五行、体用生克所定的吉凶、十七到二十一段断语(含卦气当令主卦、六冲六合、爻之合、爻之刑)、应期、宜忌与起卦依据。问事请尽量写清楚,写了问题与不写问题解出来的侧重不同。默认时间起卦,按当下时辰成卦,两小时一换。', + { + type: 'object', + properties: { + ...QUESTION_PROPERTY, + method: { + type: 'string', + enum: ['time', 'daily', 'numbers', 'coins'], + description: + '起法。time=以当下时辰成卦(两小时一换);daily=按今日日期成卦(一天一换);numbers=由你给两个正整数,默念所问之后自行取数,一上卦一下卦;coins=由本工具掷六次铜钱,每次皆不同。用户说「掷铜钱」或要随机时用 coins,说「今天」用 daily。', + default: 'time', + }, + upper: { type: 'integer', minimum: 1, maximum: 1000000000, description: 'method=numbers 时的第一数,取上卦。' }, + lower: { type: 'integer', minimum: 1, maximum: 1000000000, description: 'method=numbers 时的第二数,取下卦。' }, + }, + additionalProperties: false, + }, + ), + tool( + 'divination_hexagram_lookup', + '查六十四卦', + '按卦名、上下卦或关键字检索六十四卦,返回卦辞、象辞、所属宫位与世应六亲、互错综三卦。用户问「谦卦什么意思」「水雷屯怎么解」时用这个,不要起新卦。默认只给这些:卦辞与象辞说得出这卦是什么,宫位与世应说得出它是谁,三卦说得出它连着什么。真要看彖传原文与六亲全表时传 detail="full",那部分动辄六十字,连查几卦就淹掉了要紧的话。', + { + type: 'object', + properties: { + query: { type: 'string', maxLength: 40, description: '卦名、上下卦名或关键字,如「乾」「雷」「风」「水天」。省略则返回六十四卦全表。' }, + limit: { type: 'integer', minimum: 1, maximum: 64, default: 8, description: '返回条数上限。' }, + detail: { type: 'string', enum: ['brief', 'full'], default: 'brief', description: 'brief 给出卦辞、象辞与三卦;full 额外附上彖传原文。' }, + }, + additionalProperties: false, + }, + ), + tool( + 'divination_almanac', + '查今日历法', + '返回今日干支纪年月日时、月建与旺衰、当前时辰、黄黑道吉时、二十四节气与生肖三合六合。用户问「今天黄历」「今天什么日子」时用这个。', + { type: 'object', properties: {}, additionalProperties: false }, + ), +]); + +const DISCLAIMER = + '本结果由传统占卜法按规则推演,所有文字由 AI 组织。卦象不构成任何建议、预测或决策依据,不应作为医疗、法律、财务等重要决定的参考。娱乐之外,请以自身判断与专业意见为准。'; + +/** @param {string} text */ +function clampText(text, max) { + return typeof text === 'string' ? text.trim().slice(0, max) : ''; +} + +class ToolError extends Error { + /** @param {string} code @param {string} message @param {string} [recovery] */ + constructor(code, message, recovery) { + super(message); + this.code = code; + this.recovery = recovery; + } +} + +/** @param {unknown} value @param {number} max */ +function toInteger(value, max) { + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + throw new ToolError('INVALID_ARGUMENTS', '数字起卦需要两个整数。', '用两个 1 到 1000000000 的正整数重试。'); + } + if (parsed < 1 || parsed > max) { + throw new ToolError('INVALID_ARGUMENTS', `数字需在 1 到 ${max} 之间。`, `把数字改到 1 到 ${max} 之间。`); + } + return parsed; +} + +/** + * Agent 自报的事类。没给就给 null(后面退回关键词);给了但不在九类里就是硬伤, + * 悄悄当没给会让 Agent 以为自己定过了,所以当场报错让它重挑。 + * @param {unknown} value + * @param {readonly string[]} allowed + * @returns {string|null} + */ +function toTopicKey(value, allowed) { + if (value === undefined || value === null || value === '') return null; + if (typeof value !== 'string' || !allowed.includes(value)) { + throw new ToolError( + 'INVALID_ARGUMENTS', + `未知的事类:${typeof value === 'string' ? value : typeof value}`, + `topic 只能是 ${allowed.join('、')} 这九个,拿不准就省略,省略后按关键词认。`, + ); + } + return value; +} + +/** + * 把一卦压成给模型读的文本。给全量 JSON 没用,模型要的是能复述的句子。 + * @param {ReturnType} reading + */ +function readingToText(reading) { + const lines = reading.lines.map((line) => `${line.label} ${line.kind}`).join('、'); + const insights = reading.insights.map((item) => `【${item.title}】${item.text}`).join('\n'); + const basis = reading.details.map((item) => `${item.label} ${item.value}`).join(';'); + // 事类是谁定的要说清楚:Agent 自己给的那一路,它自己负责;关键词撞出来的那一路 + // 可能压根没撞上(表上的词没覆盖用户的说法),模型得知道自己站在哪条路上。 + const topic = reading.topic + ? `所问事类:${reading.topic.label}(${reading.topicSource === 'explicit' ? '由 Agent 指定' : '按关键词认出'}),类神五行 ${reading.topic.element}。` + : '所问未落到已知事类,应期按用卦推。'; + // 抬头就给宫与世应:这是模型复述卦象时最常要用的两个身份,埋在断语里要它自己去找。 + const jf = reading.jingfang; + const shiLine = jf.lines[jf.shi - 1]; + const yingLine = jf.lines[jf.ying - 1]; + const jingfangLine = `${jf.palaceName}${jf.stage}卦,属${jf.element};世爻${shiLine.label}持${shiLine.relative},应爻${yingLine.label}为${yingLine.relative}`; + // 动爻的去向挂在【变卦】这一行:Agent 复述「变到哪里、往哪儿去」时看这一行就够, + // 回头生克与进退神的来历在【断语】的「化爻」一段里。 + const changedLine = reading.changed + ? `【变卦】${reading.changed.name}(上卦 ${reading.changed.upper.name}、下卦 ${reading.changed.lower.name})` + + (reading.transforms && reading.transforms.length > 0 + ? `;动爻去向 ${reading.transforms.map((t) => `${t.label}${t.relation}${t.jinTui ? `·${t.jinTui}` : ''}`).join('、')}` + : '') + : '【变卦】六爻皆静,无变卦'; + // 日辰冲爻的三路,与世应并排放在抬头上:这三样都是「今天才有的处境」, + // 模型复述卦象时最常追问「有没有被日辰冲到、又动了的爻」。三路皆空时不出这一行。 + const dayClashLine = (() => { + const clash = reading.dayClash; + if (!clash) return null; + const bits = []; + if (clash.dark.length > 0) bits.push(`暗动${clash.dark.join('、')}爻`); + if (clash.dayBroken.length > 0) bits.push(`日破${clash.dayBroken.join('、')}爻`); + if (clash.pressed.length > 0) bits.push(`冲散${clash.pressed.join('、')}爻`); + return bits.length === 0 ? null : `【日冲】${bits.join(',')}`; + })(); + // 爻之合四名。这一行只报名目与爻位,不带吉凶:原章说「宜合吉,不宜合凶」, + // 「合」字进不了断语,所以抬头也不许替它表态。 + const combineLine = (() => { + const combine = reading.combine; + if (!combine) return null; + const bits = []; + if (combine.rise.length > 0) { + bits.push(`合起${combine.rise.map(([p, source]) => `${p}爻合${source}`).join('、')}`); + } + if (combine.bind.length > 0) { + bits.push(`合绊${combine.bind.map(([p, source]) => `${p}爻合${source}`).join('、')}`); + } + if (combine.friendly.length > 0) { + bits.push(`合好${combine.friendly.map(([a, b]) => `${a}与${b}`).join('、')}`); + } + if (combine.support.length > 0) { + bits.push(`化扶${combine.support.join('、')}爻`); + } + return bits.length === 0 ? null : `【逢合】${bits.join(',')}`; + })(); + // 爻之刑。只报名目与来路,不带吉凶——原书「独犯三刑得验者少」是它的收口。 + const punishLine = (() => { + const punish = reading.punish; + if (!punish) return null; + const bits = []; + if (punish.linePairs.length > 0) { + bits.push(`卦内${punish.linePairs.map(([a, b, self]) => `${self ? '自' : ''}${a}刑${b}`).join('、')}`); + } + if (punish.outside.length > 0) { + // 自刑那一支照原样拼会成「月建酉刑酉」,读着像句病话,单独说。 + // 每一项自己都带来源,前面不再加「日月」两个字——加了会拼成「日月月建与…」。 + bits.push(punish.outside.map(([position, source, from, to, self]) => ( + self ? `${source}与${position}爻${to}自刑` : `${source}${from}刑${to}(${position}爻)` + )).join('、')); + } + return bits.length === 0 ? null : `【犯刑】${bits.join(',')}`; + })(); + // 反伏与卦变。两档分开报:卦变是六爻全动换到对宫八纯卦,反伏是内卦或外卦 + // 的纳支被换成逐位六冲的那一组。合起来报会把乾变坤漏掉——那一卦的纳支一支都不冲。 + const fanfuLine = (() => { + const fanfu = reading.fanfu; + if (!fanfu) return null; + const bits = []; + if (fanfu.guaChange) bits.push('六爻全动,换到对宫的八纯卦'); + const swaps = []; + if (fanfu.inner) swaps.push(`内卦${fanfu.innerFrom}→${fanfu.innerTo}`); + if (fanfu.outer) swaps.push(`外卦${fanfu.outerFrom}→${fanfu.outerTo}`); + if (swaps.length > 0) bits.push(`纳支逐位相冲:${swaps.join(',')}`); + return bits.length === 0 ? null : `【反伏与卦变】${fanfu.kind}|${bits.join(';')}`; + })(); + // 卦体冲合。六冲卦十个、六合卦八个,都是整卦的定性;变卦那两路要等动起来才谈得上。 + // 六十四卦里有四十六卦既不是六冲也不是六合,那种「不是」不值一行,不出。 + const clashLine = (() => { + const c = reading.clash; + if (!c) return null; + if (!(c.chong || c.he || c.changedChong || c.changedHe || (c.transformClash || []).length > 0)) return null; + const bits = []; + bits.push(c.chong ? '本卦六冲卦' : c.he ? '本卦六合卦' : '本卦非六冲非六合'); + if (c.chongToChong) bits.push('变卦亦六冲'); + else if (c.heToChong) bits.push('变卦六冲(六合变六冲)'); + else if (c.changedChong) bits.push('变卦六冲'); + if ((c.transformClash || []).length > 0) bits.push(`动爻变冲${c.transformClash.join('、')}爻`); + return `【卦体】${bits.join(',')}`; + })(); + return [ + `【起法】${reading.method}`, + reading.question ? `【所问】${reading.question}` : '【所问】未填', + topic, + `【卦名】${reading.hexagram.name}(第 ${reading.hexagram.order} 卦,${reading.hexagram.symbol}),上卦 ${reading.hexagram.upper.name}${reading.hexagram.upper.element}、下卦 ${reading.hexagram.lower.name}${reading.hexagram.lower.element}`, + changedLine, + `【爻象】${lines}`, + `【体用】体卦 ${reading.structure.body.name}${reading.structure.body.element},用卦 ${reading.structure.use.name}${reading.structure.use.element}`, + `【京房】${jingfangLine}`, + clashLine, + dayClashLine, + combineLine, + punishLine, + fanfuLine, + `【月令旺衰】当令 ${reading.structure.monthElement},体 ${reading.structure.bodyVitality}、用 ${reading.structure.useVitality}`, + `【吉凶】${reading.verdict.label} —— ${reading.verdict.summary}`, + `【断语】\n${insights}`, + `【宜】${reading.advice.suitable.join('、')}`, + `【忌】${reading.advice.avoid.join('、')}`, + `【起卦依据】${basis}`, + // 白话块放在最末,而不是塞在【吉凶】旁边:上面那些是给模型复述卦象用的, + // 这一段是给用户听的,位置就跟着「请照着这几行讲」走,不必混在术语里。 + `【大白话】\n${plainBlock(reading)}`, + `【提示】${DISCLAIMER}`, + ].filter(Boolean).join('\n'); +} + +/** 白话块各句。空的那句(没定事类时的取象)直接不占一行,不留空壳。 */ +function plainBlock(reading) { + const plain = reading.plain; + return [ + plain.ask, + plain.topic, + plain.verdict, + plain.why, + plain.onTopic, + plain.timing, + plain.actions, + plain.caution, + ].filter(Boolean).join('\n'); +} + +/** @param {string} name @param {Record} args */ +function callTool(name, args) { + if (name === 'divination_cast') { + const question = clampText(args.question, 120); + const topic = toTopicKey(args.topic, TOPIC_KEYS); + const method = typeof args.method === 'string' ? args.method : 'time'; + const now = new Date(); + /** @type {ReturnType} */ + let reading; + if (method === 'daily') { + reading = buildReading(castDaily(now), { question, now, topic }); + } else if (method === 'numbers') { + const upper = toInteger(args.upper, 1000000000); + const lower = toInteger(args.lower, 1000000000); + reading = buildReading(castByNumbers(upper, lower), { question, now, topic }); + } else if (method === 'coins') { + // tossCoins() 掷一次、返回一个 { sum, coins } 对象;castByCoins() 要的是 + // 自初爻起的六次结果组成的数组。把对象直接递进去,.length 是 undefined, + // 于是每一次 coins 调用都撞在「六次掷钱结果必须是 6 到 9 之间的整数」上—— + // 页面那条路是分开收六次再组卦,所以只有 MCP 这一处坏。 + reading = buildReading(castByCoins(Array.from({ length: 6 }, () => tossCoins().sum)), { question, now, topic }); + } else if (method === 'time') { + reading = buildReading(castByTime(now), { question, now, topic }); + } else { + throw new ToolError('INVALID_ARGUMENTS', `未知的起法:${method}`, 'method 只能是 time、daily、numbers 或 coins。'); + } + return { + content: [{ type: 'text', text: readingToText(reading) }], + structuredContent: { + method: reading.method, + question: reading.question, + topic: reading.topic, + hexagram: { name: reading.hexagram.name, order: reading.hexagram.order }, + changed: reading.changed ? { name: reading.changed.name, order: reading.changed.order } : null, + verdict: { label: reading.verdict.label, key: reading.verdict.key }, + useGod: reading.useGod + ? { + relatives: reading.useGod.relatives, + present: reading.useGod.present, + absent: reading.useGod.absent, + picked: reading.useGod.picked + ? { label: reading.useGod.picked.label, relative: reading.useGod.picked.relative, branch: reading.useGod.picked.branch, element: reading.useGod.picked.element, position: reading.useGod.picked.position } + : null, + why: reading.useGod.why, + // 用神周围那一圈:元神、忌神、仇神。断语里已讲过一遍,Agent 要复述 + // 「谁生着它、谁克着它、谁在背后使坏」时不必再从正文里刨。 + circle: reading.useGod.circle + ? { + yuan: reading.useGod.circle.yuan, + ji: reading.useGod.circle.ji, + chou: reading.useGod.circle.chou, + elements: reading.useGod.circle.elements, + } + : null, + // 用神不上卦时从本宫首卦取伏神,一并带出去:断语里已经讲过一遍, + // Agent 要复述「伏在哪、飞神是谁」时不必再从正文里刨。 + hidden: (reading.useGod.hidden || []).map((h) => ({ + relative: h.relative, + position: h.position, + hushen: h.hushen, + feishen: h.feishen, + feishenRelative: h.feishenRelative, + flying: h.flying, + emerges: h.emerges, + })), + } + : null, + // 每一动爻变出来的那一爻:回头生克与进退神。断语里已经讲过一遍, + // Agent 要复述「这一爻往哪儿去」时不必再从正文里刨。 + transforms: (reading.transforms || []).map((t) => ({ + position: t.position, + label: t.label, + moving: t.moving, + movingRelative: t.movingRelative, + changed: t.changed, + changedRelative: t.changedRelative, + relation: t.relation, + jinTui: t.jinTui, + marks: t.marks, + })), + // 日辰冲到的爻分三路:静爻旺相作暗动、静爻休囚作日破、动爻作冲散。断语里已讲过一遍, + // Agent 要复述「今天有哪些爻被日辰冲到」时不必再从正文里刨。三个爻位数组互不相交。 + dayClash: reading.dayClash + ? { + dark: reading.dayClash.dark, + dayBroken: reading.dayClash.dayBroken, + pressed: reading.dayClash.pressed, + } + : null, + // 卦体冲合:六冲卦十个、六合卦八个,都是整卦的定性,一卦至多中一个; + // 变卦那两路(六合变六冲、六冲变六冲)与动爻变冲要等动起来才谈得上。 + // 卦内零散爻与爻冲六十四卦里有三十卦都有,太常见,不单列开关,只给爻位对。 + // 爻之合那四名各是哪些爻。合起/合绊是「合于日月」,按动静分两路,互不相交; + // 合好是两动爻相合(成对给出),化扶是动爻化出之爻回头相合(只给本爻位)。 + // 四路皆空时是空对象,不占结构。卦级那三法在 clash 里,不在这里重复。 + // 爻之刑。方向原样给出(谁刑谁),不折成「这几爻犯刑」——折了就看不出是谁动的。 + // 刑本身不是判词:三刑章说独犯三刑得验者少,所以这里只给位置与来路。 + // 反伏与卦变。两档不合成一条:卦变换过去的纳支并不逐位相冲,合起来报会漏掉乾变坤。 + // 也不由这一层下吉凶——章末那两句都以用神为轴,判词仍归用神旺衰。 + fanfu: reading.fanfu ?? null, + punish: reading.punish ?? null, + combine: reading.combine ?? null, + clash: reading.clash, + timing: reading.timing, + disclaimer: DISCLAIMER, + }, + }; + } + + if (name === 'divination_hexagram_lookup') { + const query = clampText(args.query, 40); + const requested = Number.isInteger(args.limit) ? /** @type {number} */ (args.limit) : 8; + const limit = Math.min(Math.max(requested, 1), 64); + // detail 默认 brief:卦辞与象辞说得出这卦是什么,三卦说得出它连着什么,够回答 + // 「谦卦什么意思」这类问题。彖传动辄六十字,连查八卦就是近五百字,多半用不上, + // 却把要紧的话埋在中间。要看原文时显式传 detail="full"。 + const detail = args.detail === 'full' ? 'full' : 'brief'; + const hits = query + ? HEXAGRAM_LIST.filter( + (item) => + item.name.includes(query) || + item.upperTrigram.name.includes(query) || + item.lowerTrigram.name.includes(query) || + item.upperTrigram.image.includes(query) || + item.lowerTrigram.image.includes(query) || + item.judgment.includes(query) || + item.tuan.includes(query), + ) + : HEXAGRAM_LIST; + const picked = hits.slice(0, limit); + // 宫位与世应各一行就够说明「这卦是谁」,六亲全表连干支约三十字,压到 full 里。 + const palaceLine = (item) => { + const jf = jingfang(item); + const shi = jf.lines[jf.shi - 1]; + const ying = jf.lines[jf.ying - 1]; + return `${jf.palaceName}${jf.stage}卦(属${jf.element}),世${shi.label}持${shi.relative},应${ying.label}为${ying.relative}`; + }; + const relativesLine = (item) => { + const jf = jingfang(item); + return jf.lines + .map((line) => `${line.stem}${line.branch}${line.element}${line.relative}${line.role ? `持${line.role}` : ''}`) + .join(' '); + }; + const text = picked + .map((item) => + [ + `【${item.name}】第 ${item.order} 卦,${hexagramSymbol(item.key)},上${item.upperTrigram.name}下${item.lowerTrigram.name}`, + `卦辞:${item.judgment}`, + detail === 'full' ? `彖传:${item.tuan}` : null, + `象辞:${item.image}`, + `京房:${palaceLine(item)}`, + detail === 'full' ? `六亲:${relativesLine(item)}` : null, + `互卦 ${mutualHexagram(item).name},错卦 ${oppositeHexagram(item).name},综卦 ${invertedHexagram(item).name}`, + ] + .filter(Boolean) + .join('\n'), + ) + .join('\n\n'); + return { + content: [ + { + type: 'text', + text: picked.length === 0 + ? `没有匹配「${query}」的卦。` + : `${query ? `匹配「${query}」的卦共 ${hits.length} 个,` : ''}如下:\n\n${text}` + + `${detail === 'brief' ? '\n\n(以上省去了彖传原文;需要时传 detail="full" 补上。)' : ''}` + + `\n\n${DISCLAIMER}`, + }, + ], + structuredContent: { count: hits.length, detail, hexagrams: picked.map((item) => ({ name: item.name, order: item.order })) }, + }; + } + + if (name === 'divination_almanac') { + const snapshot = almanac(new Date()); + // hour 只有干支序号,吉凶与时柱在 hours 的完整条目里;数九只在三九、九九两段有。 + const current = snapshot.hours.find((item) => item.current) ?? snapshot.hour; + const text = [ + `【日期】${snapshot.date}`, + `【干支】${snapshot.year.name}年 ${snapshot.month.name}月 ${snapshot.day.name}日 ${current.pillar}时`, + `【节气】${snapshot.currentTerm},月建 ${snapshot.month.name}(${snapshot.month.element})`, + `【当前时辰】${current.name}(${current.range},${current.office}·${current.officeType}${current.verdict})`, + `【黄黑道吉时】${snapshot.luckyHours.join('、')}`, + `【建除十二神】${snapshot.jianchu.name}`, + snapshot.shujiu ? `【数九】${snapshot.shujiu}` : '【数九】未入数九(数九只在三九、九九两段)', + DISCLAIMER, + ].join('\n'); + return { content: [{ type: 'text', text }], structuredContent: snapshot }; + } + + throw new ToolError('TOOL_NOT_FOUND', `未知工具:${name}`, '先调用 tools/list,用返回的工具名重试。'); +} + +/** + * @param {unknown} message + * @returns {Promise} + */ +async function handleMessage(message) { + if (!message || typeof message !== 'object' || /** @type {any} */ (message).id === undefined) { + return null; // 通知:Streamable HTTP 下直接 202。 + } + const { id, method, params } = /** @type {any} */ (message); + if (method === 'initialize') { + return { + jsonrpc: '2.0', + id, + result: { + protocolVersion: params?.protocolVersion ?? '2025-11-25', + capabilities: { tools: { listChanged: false } }, + serverInfo: SERVER_INFO, + instructions: + '起卦前先问清或直接采用用户的所问之事,并用 topic 报出它属于哪一类——' + + '事类只改应期、用神与取象,不改卦体吉凶。' + + '结尾的【大白话】是给用户听的那一段,照着它讲,不要拿术语复述。' + + '解读要连「所问」一起讲,不要只复述卦辞。' + + '任何一次起卦的结果都要带上免责说明:卦象由传统占卜法推演,不构成建议、预测或决策依据。', + }, + }; + } + if (method === 'ping') return { jsonrpc: '2.0', id, result: {} }; + if (method === 'tools/list') return { jsonrpc: '2.0', id, result: { tools: TOOLS } }; + if (method === 'tools/call') { + try { + const result = callTool(/** @type {string} */ (params?.name), params?.arguments ?? {}); + return { jsonrpc: '2.0', id, result }; + } catch (error) { + const failure = /** @type {any} */ (error); + return { + jsonrpc: '2.0', + id, + result: { + content: [{ type: 'text', text: failure.message ?? '起卦失败。' }], + isError: true, + ...(failure.recovery ? { _meta: { recovery: failure.recovery } } : {}), + }, + }; + } + } + return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } }; +} + +/** + * Streamable HTTP:POST 单条或批量 JSON-RPC;纯通知返回 202。 + * @param {{ response: import('node:http').ServerResponse, body: unknown }} input + */ +export async function handleMcpRequest({ response, body }) { + const messages = Array.isArray(body) ? body : [body]; + const replies = []; + for (const message of messages) { + const reply = await handleMessage(message); + if (reply) replies.push(reply); + } + if (replies.length === 0) { + response.writeHead(202, { 'content-type': 'application/json; charset=utf-8' }); + response.end(); + return; + } + const payload = JSON.stringify(replies.length === 1 ? replies[0] : replies); + response.writeHead(200, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(payload), + }); + response.end(payload); +} + +export { TOOLS }; diff --git a/plugins/weekbin/chinese-divination/miniapp/node/miniapp-api.ts b/plugins/weekbin/chinese-divination/miniapp/node/miniapp-api.ts new file mode 100644 index 0000000..7a0e0fd --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/node/miniapp-api.ts @@ -0,0 +1,109 @@ +/** + * Agent-facing Mini App runtime authoring declarations. + * + * Copy this file into a generated plugin for type checking. It contains no Host implementation; + * the Host injects runtime values through start(context). + * Keep the .ts filename: Electron packaging excludes .d.ts files from dependency assets. + */ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: 'HostConnectorToolRef'; +}; + +export interface HostConnectorTool { + readonly toolRef: HostConnectorToolRef; + readonly provider: string; + readonly name: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly outputSchema?: JsonValue; +} + +export interface HostConnectorListResult { + readonly tools: readonly HostConnectorTool[]; + readonly partial: boolean; +} + +export interface HostConnectorCallOptions { + readonly signal?: AbortSignal; +} + +export interface HostConnectorCallResult { + readonly invocationId: string; + /** + * Raw provider result; it is not normalized by the Host and may be an object, array, or primitive. + * A single text-block array is one provider shape, not a global Host transport contract. + * Decode only a probe-observed envelope; preserve every other value, including direct strings. + */ + readonly value: JsonValue; +} + +export interface HostConnectorClient { + /** Candidate-safe inventory only; available before and after activation. */ + list(options?: HostConnectorCallOptions): Promise; + /** Activation-only business dispatch; call from request handling, never start(context). */ + call( + toolRef: HostConnectorToolRef, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; +} + +export type HostConnectorErrorDisposition = + | 'not_dispatched' + | 'provider_reported' + | 'unknown_after_dispatch'; + +export type HostConnectorErrorCode = + | 'TOOL_REF_STALE' + | 'SERVICE_RESTARTED' + | 'REQUEST_CANCELLED' + | 'CONNECTOR_TIMEOUT' + | 'INVALID_ARGUMENTS' + | 'CONNECTOR_PROVIDER_ERROR' + | 'CONNECTOR_UNAVAILABLE' + | 'CONNECTOR_OUTCOME_UNKNOWN'; + +export interface HostConnectorError extends Error { + readonly code: HostConnectorErrorCode; + readonly disposition: HostConnectorErrorDisposition; + readonly retryable: boolean; + readonly invocationId?: string; + readonly diagnostic?: { + readonly issues: readonly { + readonly path: string; + readonly constraint: string; + readonly limit?: number; + }[]; + }; +} + +export interface MiniAppLogger { + debug(message: string, fields?: JsonObject): void; + info(message: string, fields?: JsonObject): void; + warn(message: string, fields?: JsonObject): void; + error(message: string, fields?: JsonObject): void; +} + +export interface MiniAppLifecycle { + dispose(): void | Promise; +} + +export interface MiniAppContext { + readonly pluginId: string; + readonly pluginRoot: string; + readonly dataDir: string; + readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; + readonly signal: AbortSignal; + readonly logger: MiniAppLogger; + readonly hostConnector?: HostConnectorClient; +} + +export interface MiniAppModule { + /** Resolve only after the listener accepts connections and every route is installed. */ + start(context: MiniAppContext): Promise; +} diff --git a/plugins/weekbin/chinese-divination/miniapp/node/server.mjs b/plugins/weekbin/chinese-divination/miniapp/node/server.mjs new file mode 100644 index 0000000..fd6ec8b --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/node/server.mjs @@ -0,0 +1,377 @@ +// @ts-check + +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; + +import { almanac, zodiacProfile, SOLAR_TERMS, ELEMENTS } from './almanac.mjs'; +import { + buildReading, + castByCoins, + castByNumbers, + castByTime, + castDaily, + tossCoins, +} from './divination.mjs'; +import { HEXAGRAM_LIST, TRIGRAMS, hexagramSymbol, invertedHexagram, mutualHexagram, oppositeHexagram } from './hexagrams.mjs'; +import { MAX_NOTE, MAX_QUESTION, ReadingStore, clamp } from './store.mjs'; +import { jingfang } from './jingfang.mjs'; +import { handleMcpRequest } from './mcp/divination-http.mjs'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + +const SURFACE_PATH = '/divination'; +const API_ROOT = '/api/divination'; +/** MCP 端点路径,须与 miniapp.json 的 mcpEndpoints 一致。 */ +const MCP_PATH = '/mcp/divination'; +const MAX_BODY_BYTES = 64 * 1024; +const MAX_NUMBER = 1_000_000_000; + +/** + * 允许的 Host / Origin 主机名。Host 给的 listen 恒为回环(docs/runtime.md), + * 这里再钉一道,是为堵 DNS rebinding:恶意页面把自有域名解析到 127.0.0.1 之后, + * 浏览器认为那是同源,Host 头就带着攻击者的域名打到这里。若不校验, + * `GET /api/divination/history` 里的卦题与批注会被别的网站读走。 + * 只认回环,不认具体端口——端口由 Host 分配,这里猜不得也不该猜。 + */ +const LOOPBACK_HOSTNAMES = new Set(['localhost', '::1', '0:0:0:0:0:0:0:1']); + +/** 一个合法八位组:0-255,且不认前导零(Node 的 URL 解析同样不认)。 */ +const OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)'; +const LOOPBACK_IPV4 = new RegExp(`^127\\.${OCTET}\\.${OCTET}\\.${OCTET}$`); + +/** + * @param {string} hostname + * @returns {boolean} + */ +export function isLoopbackHostname(hostname) { + const normalized = hostname.trim().toLowerCase(); + if (LOOPBACK_HOSTNAMES.has(normalized)) return true; + // 127.0.0.0/8 整段都是回环。浏览器不会替攻击者页面发这种 Host, + // 所以放行整段不引入风险,却能覆盖 Host 绑到 127.0.0.2 之类的情况。 + return LOOPBACK_IPV4.test(normalized); +} + +/** + * 从 `Host: 127.0.0.1:41999` / `Host: [::1]:41999` 里取出主机名部分。 + * 端口连同分隔符一起去掉;没有端口就是整串。 + * @param {string} header + * @returns {string} + */ +export function hostnameFromHeader(header) { + const trimmed = header.trim(); + // IPv6 必须带方括号,否则一串 ::1 会被按最后一个冒号切开 + const bracketed = /^\[([^\]]*)\]/.exec(trimmed); + if (bracketed) return bracketed[1]; + const colon = trimmed.lastIndexOf(':'); + return colon === -1 ? trimmed : trimmed.slice(0, colon); +} + +/** + * 这一请求的 Host / Origin 是不是本机自己发出来的。 + * + * 两个头各堵一条路,少一条都留着口子: + * - Host 管 DNS rebinding。攻击者的域名解析到 127.0.0.1 之后,浏览器把它当同源, + * Host 头带的就是攻击者域名,不看就会把卦历读出去。 + * - Origin 管跨源简单请求。请求直接打到 127.0.0.1 时 Host 头是 legit 的, + * 挡不住;但浏览器会带上 `Origin: https://evil.example.com`,认这个才拦得住。 + * + * 头不存在就放行:Host 的 MCP 客户端是 Node 程序,不发 Origin; + * HTTP/1.0 也不带 Host。缺头不是伪造的信号,据此拒绝只会打断正常调用。 + * @param {string | undefined} hostHeader + * @param {string | undefined} originHeader + */ +export function isLocalRequest(hostHeader, originHeader) { + if (hostHeader !== undefined && !isLoopbackHostname(hostnameFromHeader(hostHeader))) return false; + if (originHeader === undefined) return true; + // Origin 的形态是 scheme://host[:port] + const originHost = /^[a-z][a-z0-9+.-]*:\/\/(\[[^\]]*\]|[^/:?#]+)/i.exec(originHeader); + if (!originHost) return false; + return isLoopbackHostname(hostnameFromHeader(originHost[1])); +} + +/** + * 日志只报错误码,不报 error.message。 + * + * Node 的 fs 报错会把完整绝对路径连同操作系统用户名写进 message: + * `EACCES: permission denied, open '/Users/<用户名>/…/readings.json'`。 + * dataDir 的位置按契约是不透明的,那串路径不该跟着日志离开这个进程—— + * 日志会被贴进 issue、被转进工单、被上传。错误码足够定位,不带路径。 + * (EISDIR 是唯一被 Node 特殊处理、不带路径的码,不能拿它代表其余。) + * @param {unknown} error + * @returns {string} + */ +export function describeError(error) { + if (!(error instanceof Error)) return 'unknown'; + const code = /** @type {NodeJS.ErrnoException} */ (error).code; + return typeof code === 'string' ? code : error.name; +} + +/** + * 页面与 Node 服务之间的唯一数据通道。所有写操作都经由这里落到 dataDir。 + * @param {MiniAppContext} context + * @returns {Promise} + */ +export async function start(context) { + const clientEntry = await readFile(join(context.pluginRoot, 'miniapp/client/index.html')); + const store = new ReadingStore(context.dataDir); + + const server = createServer((request, response) => { + handle(request, response, clientEntry, store).catch((error) => { + context.logger.error(`divination.request.failed ${describeError(error)}`); + sendJson(response, 500, { error: 'internal_error' }); + }); + }); + + await listen(server, context.listen.host, context.listen.port); + context.logger.info('divination.runtime.listening'); + + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + context.signal.removeEventListener('abort', onAbort); + await close(server); + }; + const onAbort = () => { + void dispose(); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} + +/** + * @param {import('node:http').IncomingMessage} request + * @param {import('node:http').ServerResponse} response + * @param {Buffer} clientEntry + * @param {ReadingStore} store + */ +async function handle(request, response, clientEntry, store) { + const url = new URL(request.url ?? '/', 'http://miniapp.local'); + const path = url.pathname; + const method = request.method ?? 'GET'; + + // 头不认本机就整个拒掉,且不回显它是什么:回显等于替攻击者确认服务存在。 + if (!isLocalRequest(request.headers.host, request.headers.origin)) { + sendJson(response, 403, { error: 'forbidden' }); + return; + } + + // MCP 端点:只在本机回环上提供 POST,Agent 通过它主动起卦。 + if (path === MCP_PATH) { + if (method === 'POST') { + const body = await readJsonBody(request); + if (body === null) { + sendJson(response, 400, { error: 'invalid_body' }); + return; + } + await handleMcpRequest({ response, body }); + return; + } + response.writeHead(405, { allow: 'POST', 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ error: 'method_not_allowed' })); + return; + } + + if (method === 'GET' && path === SURFACE_PATH) { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); + response.end(clientEntry); + return; + } + + if (method === 'GET' && path === `${API_ROOT}/bootstrap`) { + const now = new Date(); + sendJson(response, 200, { + almanac: almanac(now), + trigrams: Object.values(TRIGRAMS), + elements: ELEMENTS, + solarTerms: SOLAR_TERMS, + zodiac: Array.from({ length: 12 }, (_unused, index) => zodiacProfile(index)), + today: castDaily(now), + }); + return; + } + + if (method === 'GET' && path === `${API_ROOT}/library`) { + sendJson(response, 200, { + hexagrams: HEXAGRAM_LIST.map((hexagram) => ({ + order: hexagram.order, + key: hexagram.key, + name: hexagram.name, + symbol: hexagramSymbol(hexagram.key), + upper: hexagram.upperTrigram.name, + lower: hexagram.lowerTrigram.name, + element: hexagram.upperTrigram.element === hexagram.lowerTrigram.element + ? hexagram.upperTrigram.element + : `${hexagram.upperTrigram.element}${hexagram.lowerTrigram.element}`, + judgment: hexagram.judgment, + tuan: hexagram.tuan, + image: hexagram.image, + // 与 jingfang() 同形,客户端卦体那一列直接拿来渲染,不必再转一次 + palace: jingfang(hexagram), + mutual: mutualHexagram(hexagram).name, + opposite: oppositeHexagram(hexagram).name, + inverted: invertedHexagram(hexagram).name, + })), + }); + return; + } + + if (method === 'GET' && path === `${API_ROOT}/history`) { + sendJson(response, 200, { entries: await store.list() }); + return; + } + + const historyDetail = /^\/api\/divination\/history\/([A-Za-z0-9-]{1,80})$/u.exec(path); + if (historyDetail) { + const id = historyDetail[1]; + if (method === 'GET') { + const entry = await store.get(id); + if (!entry) { + sendJson(response, 404, { error: 'not_found' }); + return; + } + sendJson(response, 200, { entry }); + return; + } + if (method === 'DELETE') { + const removed = await store.remove(id); + sendJson(response, removed ? 200 : 404, removed ? { removed: true } : { error: 'not_found' }); + return; + } + } + + if (method === 'POST' && path === `${API_ROOT}/toss`) { + const toss = tossCoins(); + sendJson(response, 200, { + sum: toss.sum, + coins: toss.coins, + kind: toss.sum === 9 ? '老阳' : toss.sum === 8 ? '少阴' : toss.sum === 7 ? '少阳' : '老阴', + }); + return; + } + + if (method === 'POST' && path === `${API_ROOT}/cast`) { + const body = await readJsonBody(request); + if (body === null) { + sendJson(response, 400, { error: 'invalid_body' }); + return; + } + let cast; + try { + cast = castFrom(body); + } catch (error) { + sendJson(response, 400, { error: 'invalid_cast', message: error instanceof Error ? error.message : 'invalid cast' }); + return; + } + const question = clamp(body.question, MAX_QUESTION); + const now = new Date(); + sendJson(response, 200, { reading: buildReading(cast, { question, now }) }); + return; + } + + if (method === 'POST' && path === `${API_ROOT}/history`) { + const body = await readJsonBody(request); + if (body === null || !body.reading || typeof body.reading !== 'object') { + sendJson(response, 400, { error: 'invalid_body' }); + return; + } + const reading = body.reading; + if (typeof reading.hexagram !== 'object' || typeof reading.id !== 'string') { + sendJson(response, 400, { error: 'invalid_reading' }); + return; + } + const saved = await store.save( + /** @type {any} */ (reading), + clamp(body.note, MAX_NOTE), + ); + sendJson(response, 201, { entry: saved }); + return; + } + + sendJson(response, 404, { error: 'not_found' }); +} + +/** + * 页面传来的起卦请求。每一项都做范围与类型校验,页面内容不可信。 + * @param {Record} body + */ +function castFrom(body) { + const method = typeof body.method === 'string' ? body.method : ''; + if (method === 'time') return castByTime(new Date()); + if (method === 'daily') return castDaily(new Date()); + if (method === 'numbers') { + const upper = toBoundedInteger(body.upper, MAX_NUMBER); + const lower = toBoundedInteger(body.lower, MAX_NUMBER); + return castByNumbers(upper, lower); + } + if (method === 'coins') { + const sums = Array.isArray(body.sums) ? body.sums.map((item) => Number(item)) : []; + return castByCoins(sums); + } + throw new Error(`unknown method "${method}"`); +} + +/** + * @param {unknown} value + * @param {number} max + * @returns {number} + */ +function toBoundedInteger(value, max) { + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) throw new Error('数字起卦需要两个整数'); + if (parsed < 1 || parsed > max) throw new Error(`数字需在 1 到 ${max} 之间`); + return parsed; +} + +/** + * @param {import('node:http').IncomingMessage} request + * @returns {Promise|null>} + */ +async function readJsonBody(request) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > MAX_BODY_BYTES) return null; + chunks.push(chunk); + } + if (chunks.length === 0) return {}; + try { + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** @param {import('node:http').ServerResponse} response @param {number} status @param {unknown} payload */ +function sendJson(response, status, payload) { + const body = JSON.stringify(payload); + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(body), + 'cache-control': 'no-store', + }); + response.end(body); +} + +function listen(server, host, port) { + return new Promise((resolve, reject) => { + const onError = (error) => reject(error); + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + resolve(); + }); + }); +} + +function close(server) { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} diff --git a/plugins/weekbin/chinese-divination/miniapp/node/store.mjs b/plugins/weekbin/chinese-divination/miniapp/node/store.mjs new file mode 100644 index 0000000..b85e370 --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/node/store.mjs @@ -0,0 +1,181 @@ +// @ts-check + +/** + * 卦历持久化。所有状态写在 context.dataDir 下的单个 JSON 文件里,原子替换,避免写坏。 + */ + +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; + +const MAX_ENTRIES = 500; +const MAX_QUESTION = 120; +const MAX_NOTE = 2000; + +/** + * 落盘是「写临时文件再改名」。在 Windows 上,改名可能整个失败: + * 目标文件正被别的进程打开且未共享删除权限时(杀毒扫描、Windows Search 索引 + * 都可能占着),Node 报 EPERM 或 EACCES。这不是部分写入——是压根没换。 + * 退避重试几次,对方松手就成了。其余错误码重试也没用,直接抛。 + * 累计等待约 400ms,对一次保存来说察觉不到。 + */ +const RENAME_ATTEMPTS = 4; +const RENAME_BACKOFF_MS = 40; +const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']); + +/** + * 该不该为这个错误再试一次。 + * @param {unknown} error + * @param {number} attempt 已经重试过几次 + * @returns {boolean} + */ +export function shouldRetryRename(error, attempt) { + if (attempt >= RENAME_ATTEMPTS) return false; + const code = /** @type {NodeJS.ErrnoException} */ (error)?.code; + return typeof code === 'string' && RENAME_RETRY_CODES.has(code); +} + +export class ReadingStore { + /** @param {string} dataDir */ + constructor(dataDir) { + this.file = join(dataDir, 'readings.json'); + /** @type {Promise} */ + this.queue = Promise.resolve([]); + } + + async list() { + return this.enqueue(async () => { + const entries = await this.readAll(); + return entries.map(toSummary); + }); + } + + /** @param {string} id */ + async get(id) { + return this.enqueue(async () => { + const entries = await this.readAll(); + return entries.find((entry) => entry.id === id) ?? null; + }); + } + + /** + * @param {import('./divination.mjs').Reading} reading + * @param {string} note + */ + async save(reading, note) { + return this.enqueue(async () => { + const entries = await this.readAll(); + const entry = { + ...reading, + note: clamp(note, MAX_NOTE), + }; + entries.unshift(entry); + const trimmed = entries.slice(0, MAX_ENTRIES); + await this.writeAll(trimmed); + return toSummary(entry); + }); + } + + /** + * 改批注这条路原先留着 update(),但服务端没有对应的路由、客户端也不调它, + * 它写下的 updatedAt 只有 toSummary 会读,而那永远读不到——是个恒为 undefined + * 的死字段。与其留着一条永远走不到的分支,不如等「改批注」真要做时连它一起加回来。 + */ + + /** @param {string} id */ + async remove(id) { + return this.enqueue(async () => { + const entries = await this.readAll(); + const next = entries.filter((entry) => entry.id !== id); + if (next.length === entries.length) return false; + await this.writeAll(next); + return true; + }); + } + + /** 串行化写入,避免并发请求交错写文件。 */ + enqueue(task) { + const next = this.queue.then(task, task); + this.queue = next.then( + () => undefined, + () => undefined, + ); + return next; + } + + async readAll() { + let raw; + try { + raw = await readFile(this.file, 'utf8'); + } catch (error) { + if (error && /** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT') return []; + throw error; + } + // 读得到、却不是一份能解析的 JSON,说明这份落盘文件坏了:写到一半被打断、 + // 被别的程序改过、或者被同步软件截断。从前是直接抛,于是 list/save/remove + // 全线 500,卦历打不开,用户连自救的入口都没有。现在把坏的那份挪到一边 + // 另存、当空表继续走:卦历立刻回到能用,坏的内容留在盘上还能捞。 + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + await this.setAside(); + return []; + } + if (!Array.isArray(parsed)) return []; + return parsed.filter((entry) => entry && typeof entry.id === 'string'); + } + + /** + * 把坏掉的落盘文件挪开。不删:里面可能有用户手写的批注,捞得回来。 + * 挪不动也不抛——读路径不该因为善后失败而失败,下一次写入会直接盖掉它。 + */ + async setAside() { + try { + await rename(this.file, `${this.file}.corrupt-${Date.now()}`); + } catch { + // 善后尽力而为 + } + } + + async writeAll(entries) { + await mkdir(dirname(this.file), { recursive: true }); + const temp = `${this.file}.tmp`; + await writeFile(temp, `${JSON.stringify(entries, null, 2)}\n`, 'utf8'); + for (let attempt = 0; ; attempt += 1) { + try { + await rename(temp, this.file); + return; + } catch (error) { + if (!shouldRetryRename(error, attempt)) throw error; + await delay(RENAME_BACKOFF_MS * (attempt + 1)); + } + } + } +} + +function toSummary(entry) { + return { + id: entry.id, + method: entry.method, + question: entry.question ?? '', + note: entry.note ?? '', + createdAt: entry.createdAt, + hexagram: entry.hexagram, + changed: entry.changed, + verdict: entry.verdict + ? { key: entry.verdict.key, label: entry.verdict.label, summary: entry.verdict.summary } + : undefined, + timing: entry.timing ?? '', + topic: entry.topic ?? null, + structure: entry.structure, + }; +} + +/** @param {unknown} value @param {number} max */ +export function clamp(value, max) { + if (typeof value !== 'string') return ''; + return value.trim().slice(0, max); +} + +export { MAX_QUESTION, MAX_NOTE, MAX_ENTRIES }; diff --git a/plugins/weekbin/chinese-divination/miniapp/node/topics.mjs b/plugins/weekbin/chinese-divination/miniapp/node/topics.mjs new file mode 100644 index 0000000..2d8bec3 --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/node/topics.mjs @@ -0,0 +1,179 @@ +// @ts-check + +/** + * 五行类事:把「所问何事」落到一个事类,再由事类取类神五行与用神六亲。 + * + * 口径说明(重要):梅花易数本身没有六爻那样的六亲用神,这张表是本包**自订**的类象口径, + * 不是传世定法。它只决定两件事——应期看类神五行的旺相,取象多看类神与体卦的生克。 + * 卦的吉凶仍由体用生克单独推定,问不同的事不会把同一个卦说出相反的吉凶。 + * + * 用神(god)那一栏是另一回事,**有传世定法可依**,见 jingfang.mjs: + * 问何事取何亲为用神,出处见各条 reason。多半一亲,婚恋与疾病两亲——婚恋分男女 + * 取,本包不知道求测者是男是女,所以两个都列出来,不替人猜。 + */ + +/** + * @typedef {object} GodRule + * @property {readonly string[]} relatives 用神取哪几个六亲 + * @property {string} reason 取法依据,随断语一同给出,免得凭空断言 + * @property {boolean} [byGender] 是否男取其一、女取其一(婚恋) + * @property {string} [relate] 两亲之间的生克,疾病用(病症与医药) + * + * @typedef {object} TopicClass + * @property {string} key 事类标识 + * @property {string} label 事类名,出现在断语里 + * @property {'木'|'火'|'土'|'金'|'水'} element 类神五行 + * @property {string} reason 取此五行的类象依据,随断语一同给出,免得凭空断言 + * @property {GodRule} god 用神取法 + * @property {readonly string[]} keywords 命中用的词,按长度从长到短匹配 + */ + +/** @type {readonly TopicClass[]} */ +export const TOPIC_CLASSES = Object.freeze([ + Object.freeze({ + key: 'wealth', + god: Object.freeze({ relatives: Object.freeze(['妻财']), reason: '求财取妻财:财爻是求测者可以支配、可以到手的资源。' }), + label: '财运', + element: '金', + reason: '金为财,梅花以金主财帛、货利、得失。', + // 进货、货款、卖掉这类买卖行话原先不在表里,「这批货该不该进」「该不该进货」一律认不出, + // 问的人十有八九是问财,不该因为措辞不像「赚钱」就断成没写问题。 + keywords: Object.freeze(['工资', '薪水', '融资', '股票', '投资', '报价', '收益', '买卖', '生意', '赚钱', '亏', '钱', '财', + '进货', '货款', '订货', '利润', '营收', '卖', '销', '货']), + }), + Object.freeze({ + key: 'career', + god: Object.freeze({ relatives: Object.freeze(['官鬼']), reason: '求职、升迁取官鬼,官鬼为职位与上司;考试、文凭另取父母。' }), + label: '事业功名', + element: '火', + reason: '火主文明与显达,梅花以火主名声、职位、上位者的照拂。', + keywords: Object.freeze(['升职', '跳槽', '面试', '录用', '创业', '工作', '事业', '前程', '项目', '老板', '上司', '职位', 'offer']), + }), + Object.freeze({ + key: 'love', + god: Object.freeze({ relatives: Object.freeze(['妻财', '官鬼']), byGender: true, reason: '《增删卜易》「男测婚以财为用,女测婚以官为用」:男占取妻财(女方),女占取官鬼(男方)。本包不替求测者认性别,两亲都列出来。' }), + label: '感情', + element: '水', + reason: '水主流动与暗昧,梅花以水主情欲、暧昧、往来不定。', + keywords: Object.freeze(['暗恋', '复合', '分手', '挽回', '暧昧', '表白', '在一起', '感情', '恋爱', '交往', '心动', '主动', '回头', '喜欢', + // 「前任」「旧情」是别的类都不占的名词,加进来不会跟财运的「钱」之类抢。 + // 「回来」「找我」没加:这两个词跟财运、出行寻物共用,「这笔钱什么时候回来」 + // 会被判成感情——宁可认不出,也不把卦算到别的事上去。 + '前任', '旧情']), + }), + Object.freeze({ + key: 'marriage', + god: Object.freeze({ relatives: Object.freeze(['妻财', '官鬼']), byGender: true, reason: '《增删卜易》「男测婚以财为用,女测婚以官为用」:男占取妻财,女占取官鬼。' }), + label: '婚恋', + element: '木', + reason: '木主生发与匹配,梅花以木主婚姻、配偶、长辈之允。', + // 补一个单字「婚」:「这婚要不要定」这类写法原先认不出,而婚在九个事类里 + // 只有婚恋用,不跟任何一类相撞。 + keywords: Object.freeze(['结婚', '婚姻', '婚期', '婚', '相亲', '领证', '嫁', '娶', '配偶', '对象', '老公', '老婆']), + }), + Object.freeze({ + key: 'health', + god: Object.freeze({ relatives: Object.freeze(['官鬼', '子孙']), reason: '占病以官鬼为病症本身,以子孙为医药。', relate: '子孙克官鬼:子孙有力则病退,官鬼受制则病轻。' }), + label: '疾病', + element: '土', + reason: '土主形体与脾胃,梅花以土主肉身、疾厄、居处。', + keywords: Object.freeze(['体检', '手术', '康复', '健康', '身体', '吃药', '检查', '病']), + }), + Object.freeze({ + key: 'study', + god: Object.freeze({ relatives: Object.freeze(['父母']), reason: '父母为文书、成绩与证照,问考试取父母爻。' }), + label: '学业文书', + element: '木', + reason: '木主文昌与青卷,梅花以木主文书、科名、考试与批复。', + keywords: Object.freeze(['考试', '学业', '论文', '证照', '执照', '资格', '录取', '证书', '申请', '材料']), + }), + Object.freeze({ + key: 'property', + god: Object.freeze({ relatives: Object.freeze(['父母']), reason: '房舍、契约皆属父母,契据为父母所主。' }), + label: '房产车契', + element: '土', + reason: '土为不动之象,梅花以土主宅舍、车辆、契据与安顿。', + keywords: Object.freeze(['买房', '租房', '搬家', '置业', '装修', '贷款', '合同', '房产', '车', '房']), + }), + Object.freeze({ + key: 'dispute', + god: Object.freeze({ relatives: Object.freeze(['官鬼']), reason: '官司取官鬼,官鬼为对方与官非;我克官鬼则我胜。' }), + label: '官讼是非', + element: '金', + reason: '金主肃杀与律令,梅花以金主官非、评判、赔偿与强制。', + keywords: Object.freeze(['赔偿', '仲裁', '举报', '官司', '诉讼', '起诉', '纠纷', '是非', '立案']), + }), + Object.freeze({ + key: 'journey', + god: Object.freeze({ relatives: Object.freeze(['妻财']), reason: '失物取妻财——所求之物以财论。寻人则须按与失踪者的关系另取六亲,本包不替你猜关系。' }), + label: '出行寻物', + element: '水', + reason: '水主行旅与隐匿,梅花以水主远行、失物与寻访。', + keywords: Object.freeze(['出差', '差旅', '签证', '行程', '出行', '远行', '遗失', '失物', '丢失', '寻人', '找回', '找到', '寻', '丢']), + }), +]); + +/** 关键词长的先匹配:「找工作」不该被「工作」抢走,「买房子」不该被「房」抢走。 */ +const MATCH_ORDER = Object.freeze( + [...TOPIC_CLASSES] + .flatMap((topic) => topic.keywords.map((keyword) => ({ topic, keyword }))) + .sort((a, b) => b.keyword.length - a.keyword.length), +); + +/** @type {Map} */ +const BY_KEYWORD = new Map(MATCH_ORDER.map((entry) => [entry.keyword, entry.topic])); + +/** + * 从所问之事里认事类。没写、写得含糊、或写到认不出的事类,都返回 null, + * 由调用方退回用卦本分——宁可不给类神,也不硬套一个五行。 + * + * @param {string} question + * @returns {TopicClass | null} + */ +export function detectTopic(question) { + const text = (question ?? '').trim(); + if (!text) return null; + for (const [keyword, topic] of BY_KEYWORD) { + if (text.includes(keyword)) return topic; + } + return null; +} + +/** + * 认出事类对应的类目。MCP 让 Agent 自己挑事类时就是拿这个键来查的—— + * Agent 看得懂「这笔钱」是什么,认不出「财运」里那串关键词命中了哪一条。 + * @param {unknown} key + * @returns {TopicClass | null} + */ +export function topicByKey(key) { + return typeof key === 'string' ? (TOPIC_CLASSES.find((topic) => topic.key === key) ?? null) : null; +} + +/** + * 九个事类的键,供 MCP 把枚举列给 Agent 看。 + * @type {readonly string[]} + */ +export const TOPIC_KEYS = Object.freeze(TOPIC_CLASSES.map((topic) => topic.key)); + +/** + * 类神与体卦的关系,用来提醒所问之事对求测者是生是克。 + * @param {string} godElement 类神五行 + * @param {string} bodyElement 体卦五行 + * @param {(a: string, b: string) => boolean} generates + * @param {(a: string, b: string) => boolean} overcomes + */ +export function godRelation(godElement, bodyElement, generates, overcomes) { + if (generates(godElement, bodyElement)) { + return { key: 'god-generates-body', tone: 'good', text: `类神${godElement}生体卦${bodyElement},所问之事对你有补益,多主外力在托着你。` }; + } + if (overcomes(godElement, bodyElement)) { + return { key: 'god-overcomes-body', tone: 'bad', text: `类神${godElement}克体卦${bodyElement},所问之事压着你,多主你要主动应付、不能让其牵着走。` }; + } + if (generates(bodyElement, godElement)) { + return { key: 'body-generates-god', tone: 'bad', text: `体卦${bodyElement}生类神${godElement},你在往这件事上使力,多主你要多花心思与本钱。` }; + } + if (overcomes(bodyElement, godElement)) { + return { key: 'body-overcomes-god', tone: 'good', text: `体卦${bodyElement}克类神${godElement},这件事在你掌握之中,多主主动权归你。` }; + } + return { key: 'god-same-as-body', tone: 'flat', text: `类神与体卦同为${godElement},所问之事与你的处境同气,宜顺其势而不宜强求改变。` }; +} diff --git a/plugins/weekbin/chinese-divination/miniapp/node/tuan.mjs b/plugins/weekbin/chinese-divination/miniapp/node/tuan.mjs new file mode 100644 index 0000000..7a920c3 --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/node/tuan.mjs @@ -0,0 +1,86 @@ +// @ts-check + +/** + * 《周易》彖传,六十四卦每卦一条。 + * + * 卦辞给结论,彖传讲这个结论怎么来的——它先重述卦辞,再从上下卦的德性与爻位推出 + * 「所以然」。三传里彖传讲理、大象传讲象、小象传讲爻,卦辞、大象、小象三件此前 + * 已在 hexagrams.mjs 里,这一层补上,读者才看得到「凭什么」而不只是「是什么」。 + * + * 底本:太极之巅《易经·原文》,与藏玄易社《周易》逐条对校。两源同为繁体,可直接 + * 比对字序——64 条中 54 条全合,其余 10 条逐条裁定:9 条取底本(另一版漏字、夹注 + * 混入或与爻辞打架),乾一条按通行本订正为「保合太和」。异体、通假一仍其旧。 + */ + +/** 每卦一条,顺序与 hexagrams.mjs 的六十四卦序一致,下标 0 是第一卦。 */ +const TUAN_TEXTS = [ + '大哉乾元,万物资始,乃统天。云行雨施,品物流形。大明终始,六位时成,时乘六龙以御天。乾道变化,各正性命,保合太和,乃利贞。首出庶物,万国咸宁。', + '至哉坤元,万物资生,乃顺承天。坤厚载物,德合无疆,含弘光大,品物咸亨。牝马地类,行地无疆。柔顺利贞,君子攸行,先迷失道,后顺得常。西南得朋,乃与类行,东北丧朋,乃终有庆,安贞之吉,应地无疆。', + '屯,刚柔始交而难生,动乎险中,大亨,贞。雷雨之动,满盈。天造草昧,宜建侯而不宁。', + '蒙,山下有险,险而止,蒙。蒙,亨,以亨行,时中也。匪我求童蒙,童蒙求我,志应也。初筮告,以刚中也。再三渎,渎则不告,渎蒙也。蒙以养正,圣功也。', + '需,须也,险在前也。刚健而不陷,其义不困穷矣。需,有孚,光亨,贞吉,位乎天位,以正中也。利涉大川,往有功也。', + '讼,上刚下险,险而健。讼,讼有孚,窒惕,中吉,刚来而得中也。终凶,讼不可成也。利见大人,尚中正也。不利涉大川,入于渊也。', + '师,众也,贞,正也,能以众正,可以王矣。刚中而应,行险而顺,以此毒天下,而民从之,吉又何咎矣。', + '比,吉也。比,辅也,下顺从也。原筮元永贞,无咎,以刚中也。不宁方来,上下应也。后夫凶,其道穷也。', + '小畜,柔得位而上下应之,曰小畜。健而巽,刚中而志行,乃亨。密云不雨,尚往也;自我西郊,施未行也。', + '履,柔履刚也。说而应乎乾,是以履虎尾,不咥人,亨。刚中正,履帝位而不疚,光明也。', + '泰,小往大来,吉亨,则是天地交而万物通也,上下交而其志同也。内阳而外阴,内健而外顺,内君子而外小人。君子道长,小人道消也。', + '否之匪人,不利君子贞,大往小来,则是天地不交而万物不通也,上下不交而天下无邦也。内阴而外阳,内柔而外刚,内小人而外君子,小人道长,君子道消也。', + '同人,柔得位得中而应乎乾,曰同人。同人曰,同人于野,亨,利涉大川,乾行也。文明以健,中正而应,君子正也,唯君子为能通天下之志。', + '大有,柔得尊位大中,而上下应之,曰大有。其德刚健而文明,应乎天而时行,是以元亨。', + '谦亨,天道下济而光明,地道卑而上行;天道亏盈而益谦,地道变盈而流谦;鬼神害盈而福谦,人道恶盈而好谦。谦尊而光,卑而不可逾,君子之终也。', + '豫,刚应而志行,顺以动,豫。豫顺以动,故天地如之,而况建侯行师乎?天地以顺动,故日月不过,而四时不忒。圣人以顺动,则刑罚清而民服。豫之时义大矣哉。', + '随,刚来而下柔,动而说,随。大亨,贞,无咎,而天下随时,随时之义大矣哉。', + '蛊,刚上而柔下,巽而止,蛊。蛊元亨,而天下治也;利涉大川,往有事也;先甲三日,后甲三日,终则有始,天行也。', + '临,刚浸而长,说而顺,刚中而应,大亨以正,天之道也;至于八月有凶,消不久也。', + '大观在上,顺而巽,中正以观天下。观盥而不荐,有孚颙若,下观而化也。观天之神道,而四时不忒,圣人以神道设教,而天下服矣。', + '颐中有物,曰噬嗑。噬嗑而亨,刚柔分,动而明,雷电合而章,柔得中而上行,虽不当位,利用狱也。', + '贲亨。柔来而文刚,故亨。分刚上而文柔,故小利有攸往,天文也。文明以止,人文也。观乎天文,以察时变,观乎人文,以化成天下。', + '剥,剥也,柔变刚也。不利有攸往,小人长也。顺而止之,观象也。君子尚消息盈虚,天行也。', + '复,亨。刚反,动而以顺行,是以出入无疾,朋来无咎,反复其道,七日来复,天行也。利有攸往,刚长也。复,其见天地之心乎。', + '无妄,刚自外来而为主于内,动而健,刚中而应,大亨以正,天之命也,其匪正有眚,不利有攸往。无妄之往,何之矣。天命不佑,行矣哉。', + '大畜,刚健笃实,辉光日新其德,刚上而尚贤,能止健,大正也。不家食吉,养贤也;利涉大川,应乎天也。', + '颐,贞吉,养正则吉也。观颐,观其所养也;自求口实,观其自养也。天地养万物,圣人养贤以及万民,颐之时大矣哉。', + '大过,大者过也。栋桡,本末弱也。刚过而中,巽而说行,利有攸往,乃亨,大过之时大矣哉。', + '习坎,重险也。水流而不盈,行险而不失其信。维心亨,乃以刚中也;行有尚,往有功也。天险不可升也,地险山川丘陵也,王公设险以守其国,险之时用大矣哉。', + '离,丽也。日月丽乎天,百谷草木丽乎土,重明以丽乎正,乃化成天下。柔丽乎中正,故亨,是以畜牝牛吉也。', + '咸,感也。柔上而刚下,二气感应以相与,止而说,男下女,是以亨,利贞,取女吉也。天地感而万物化生,圣人感人心而天下和平。观其所感,而天地万物之情可见矣。', + '恒,久也。刚上而柔下,雷风相与,巽而动,刚柔皆应,恒。恒,亨,无咎,利贞,久于其道也。天地之道,恒久而不巳也,利有攸往,终则有始也,日月得天而能久照,四时变化而能久成,圣人久于其道而天下化成,观其所恒而天地万物之情可见矣。', + '遁亨,遁而亨也。刚当位而应,与时行也。小利贞,浸而长也。遁之时义大矣哉。', + '大壮,大者壮也,刚以动,故壮。大壮利贞,大者正也。正大而天地之情可见矣。', + '晋,进也。明出地上,顺而丽乎大明,柔进而上行,是以康侯用锡马蕃庶,昼日三接也。', + '明入地中,明夷。内文明而外柔顺,以蒙大难,文王以之。利艰贞,晦其明也。内难而能正其志,箕子以之。', + '家人,女正位乎内,男正位乎外,男女正,天地之大义也。家人有严君焉,父母之谓也。父父,子子,兄兄,弟弟,夫夫,妇妇,而家道正。正家,而天下定矣。', + '睽,火动而上,泽动而下,二女同居,其志不同行。说而丽乎明,柔进而上行,得中而应乎刚,是以小事吉。天地睽而其事同也,男女睽而其志通也,万物睽而其事类也,睽之时用大矣哉。', + '蹇,难也,险在前也。见险而能止,知矣哉。蹇,利西南,往得中也;不利东北,其道穷也。利见大人,往有功也;当位贞吉,以正邦也。蹇之时用大矣哉。', + '解,险以动,动而免乎险,解。解,利西南,往得众也;其来复吉,乃得中也;有攸往夙吉,往有功也。天地解而雷雨作,雷雨作而百果草木皆甲坼,解之时大矣哉。', + '损,损下益上,其道上行,损而有孚,元吉。无咎,可贞,利有攸往,曷之用,二簋可用享,二簋应有时,损刚益柔有时,损益盈虚,与时偕行。', + '益,损上益下,民说无疆,自上下下,其道大光。利有攸往,中正有庆;利涉大川,木道乃行。益动而巽,日进无疆,天施地生,其益无方,凡益之道,与时偕行。', + '夬,决也,刚决柔也。健而说,决而和。扬于王庭,柔乘五刚也;孚号有厉,其危乃光也;告自邑,不利即戎,所尚乃穷也;利有攸往,刚长乃终也。', + '姤,遇也,柔遇刚也。勿用取女,不可与长也。天地相遇,品物咸章也;刚遇中正,天下大行也,姤之时义大矣哉。', + '萃,聚也。顺以说,刚中而应,故聚也。王假有庙,致孝享也;利见大人,亨,聚以正也;用大牲,吉,利有攸往,顺天命也。观其所聚,而天地万物之情可见矣。', + '柔以时升,巽而顺,刚中而应,是以大亨;用见大人,勿恤,有庆也;南征吉,志行也。', + '困,刚掩也。险以说,困而不失其所亨,其唯君子乎。贞大人吉,以刚中也;有言不信,尚口乃穷也。', + '巽乎水而上水,井,井养而不穷也。改邑不改井,乃以刚中也;汔至亦未进井,未有功也;羸其瓶,是以凶也。', + '革,水火相息,二女同居,其志不相得,曰革。巳日乃孚,革而信之,文明以说,大亨以正,革而当,其悔乃亡。天地革而四时成,汤武革命,顺乎天而应乎人,革之时大矣哉。', + '鼎,象也。以木巽火,亨饪也。圣人亨以享上帝,而大亨以养圣贤。巽而耳目聪明,柔进而上行,得中而应乎刚,是以元亨。', + '震,亨。震来虩虩,恐致福也;笑言哑哑,后有则也;震惊百里,惊远而惧迩也,出可以守宗庙社稷,以为祭主也。', + '艮,止也,时止则止,时行则行,动静不失其时,其道光明。艮其止,止其所也。上下敌应,不相与也,是以不获其身。行其庭不见其人,无咎也。', + '渐之进也,女归吉也。进得位,往有功也,进以正,可以正邦也。其位刚得中也。止而巽,动不穷也。', + '归妹,天地之大义也。天地不交,而万物不兴。归妹,人之终始也,说以动,所归妹也。征凶,位不当也;无攸利,柔乘刚也。', + '丰,大也。明以动,故丰。王假之,尚大也;勿忧,宜日中,宜照天下也。日中则昃,月盈则食,天地盈虚,与时消息,而况于人乎?况于鬼神乎?', + '旅,小亨,柔得中乎外而顺乎刚,止而丽乎明,是以小亨,旅贞吉也,旅之时义大矣哉。', + '重巽以申命,刚巽乎中正而志行,柔皆顺乎刚,是以小亨,利有攸往,利见大人。', + '兑,说也。刚中而柔外,说以利贞,是以顺乎天而应乎人。说以先民,民忘其劳,说以犯难,民忘其死,说之大,民劝矣哉。', + '涣亨,刚来而不穷,柔得位乎外而上同。王假有庙,王乃在中也。利涉大川,乘木有功也。', + '节亨,刚柔分而刚得中。苦节不可贞,其道穷也。说以行险,当位以节,中正以通。天地节而四时成,节以制度,不伤财,不害民。', + '中孚,柔在内而刚得中,说而巽,孚,乃化邦也。豚鱼吉,信及豚鱼也;利涉大川,乘木舟虚也;中孚以利贞,乃应乎天也。', + '小过,小者过而亨也,过以利贞,与时行也。柔得中,是以小事吉也;刚失位而不中,是以不可大事也。有飞鸟之象焉,飞鸟遗之音,不宜上,宜下,大吉,上逆而下顺也。', + '既济亨,小者亨也。利贞,刚柔正而位当也。初吉,柔得中也,终止则乱,其道穷也。', + '未济亨,柔得中也;小狐汔济,未出中也;濡其尾,无攸利,不续终也,虽不当位,刚柔应也。', +]; + +/** 取某卦的彖传。order 为卦序 1 至 64。 */ +export function tuanText(order) { + return TUAN_TEXTS[order - 1] ?? null; +} diff --git a/plugins/weekbin/chinese-divination/miniapp/node/xiang-chuan.mjs b/plugins/weekbin/chinese-divination/miniapp/node/xiang-chuan.mjs new file mode 100644 index 0000000..11adad6 --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/node/xiang-chuan.mjs @@ -0,0 +1,545 @@ +// @ts-check + +/** + * 《周易》小象传,共 384 条,逐爻一条。 + * + * 爻辞讲这一爻的处境,小象传讲为什么是这么个处境:前者是断,后者是据。断卦 + * 读爻辞已经够用,小象传是拿来解释「凭什么」的,所以同样整份备全,由排盘带出, + * 不在解读时替用户做取舍。 + * + * 每条为「爻题:象辞」。爻题与 yao.mjs 完全一致,两处合起来才是一爻的完整文本。 + * + * 底本:太极之巅《易经·原文》,逐条与藏玄易社《周易》对校。两源都是繁体,可 + * 直接比对字序——384 条中 359 条全合,其余 25 条逐条裁定:7 条取后者(底本漏 + * 字、多出一句或断句有误),4 条两源皆有误、在此重写,其余取底本。异体、通假 + * 一仍其旧(如「脩井」「明辨晰」)。 + */ + +/** + * 每卦六条,自初爻至上爻;下标 0 是初爻。顺序与 hexagrams.mjs 的六十四卦序一致。 + * @type {readonly (readonly [string, string, string, string, string, string])[]} + */ +const XIANG_TEXTS = [ + [ + '初九:潜龙勿用,阳在下也。', + '九二:见龙在田,德施普也。', + '九三:终日乾乾,反复道也。', + '九四:或跃在渊,进无咎也。', + '九五:飞龙在天,大人造也。', + '上九:亢龙有悔,盈不可久也。', + ], + [ + '初六:履霜坚冰,阴始凝也。驯致其道,至坚冰也。', + '六二:六二之动,直以方也。不习无不利,地道光也。', + '六三:含章可贞,以时发也;或从王事,知光大也。', + '六四:括囊无咎,慎不害也。', + '六五:黄裳元吉,文在中也。', + '上六:龙战于野,其道穷也。', + ], + [ + '初九:虽磐桓,志行正也。以贵下贱,大得民也。', + '六二:六二之难,乘刚也。十年乃字,反常也。', + '六三:即鹿无虞,以从禽也。君子舍之,往吝穷也。', + '六四:求而往,明也。', + '九五:屯其膏,施未光也。', + '上六:泣血涟如,何可长也。', + ], + [ + '初六:利用刑人,以正法也。', + '九二:子克家,刚柔接也。', + '六三:勿用取女,行不顺也。', + '六四:困蒙之吝,独远实也。', + '六五:童蒙之吉,顺以巽也。', + '上九:利用御寇,上下顺也。', + ], + [ + '初九:需于郊,不犯难行也。利用恒,无咎,未失常也。', + '九二:需于沙,衍在中也。虽小有言,以吉终也。', + '九三:需于泥,灾在外也。自我致寇,敬慎不败也。', + '六四:需于血,顺以听也。', + '九五:酒食,贞吉,以中正也。', + '上六:不速之客来,敬之终吉,虽不当位,未大失也。', + ], + [ + '初六:不永所事,讼不可长也,虽小有言,其辩明也。', + '九二:不克讼,归逋,窜也。自下讼上,患至掇也。', + '六三:食旧德,从上吉也。', + '九四:复即命,渝安贞吉,不失也。', + '九五:讼元吉,以中正也。', + '上九:以讼受服,亦不足敬也。', + ], + [ + '初六:师出以律,失律,凶也。', + '九二:在师中吉,承天宠也;王三锡命,怀万邦也。', + '六三:师或舆尸,大无功也。', + '六四:左次无咎,未失常也。', + '六五:长子帅师,以中行也;弟子舆尸,使不当也。', + '上六:大君有命,以正功也;小人勿用,必乱邦也。', + ], + [ + '初六:比之初六,有它吉也。', + '六二:比之自内,不自失也。', + '六三:比之匪人,不亦伤乎。', + '六四:外比于贤,以从上也。', + '九五:显比之吉,位正中也;舍逆取顺,失前禽也;邑人不诫,上使中也。', + '上六:比之无首,无所终也。', + ], + [ + '初九:复自道,其义吉也。', + '九二:牵复在中,亦不自失也。', + '九三:夫妻反目,不能正室也。', + '六四:有孚惕出,上合志也。', + '九五:有孚挛如,不独富也。', + '上九:既雨既处,德积载也;君子征凶,有所疑也。', + ], + [ + '初九:素履之往,独行愿也。', + '九二:幽人贞吉,中不自乱也。', + '六三:眇能视,不足以有明也。跛能履,不足以与行也。咥人之凶,位不当也。', + '九四:愬愬,终吉,志行也。', + '九五:夬履,贞厉,位正当也。', + '上九:元吉在上,大有庆也。', + ], + [ + '初九:拔茅征吉,志在外也。', + '九二:包荒,得尚于中行,以光大也。', + '九三:无往不复,天地际也。', + '六四:翩翩不富,皆失实也;不戒以孚,中心愿也。', + '六五:以祉元吉,中以行愿也。', + '上六:城复于隍,其命乱也。', + ], + [ + '初六:拔茅贞吉,志在君也。', + '六二:大人否亨,不乱群也。', + '六三:包羞,位不当也。', + '九四:有命无咎,志行也。', + '九五:大人之吉,位正当也。', + '上九:否终则倾,何可长也。', + ], + [ + '初九:出门同人,又谁咎也。', + '六二:同人于宗,吝道也。', + '九三:伏戎于莽,敌刚也;三岁不兴,安行也。', + '九四:乘其墉,义弗克也。其吉,则困而反则也。', + '九五:同人之先,以中直也;大师相遇,言相克也。', + '上九:同人于郊,志未得也。', + ], + [ + '初九:大有初九,无交害也。', + '九二:大车以载,积中不败也。', + '九三:公用亨于天子,小人害也。', + '九四:匪其尫,无咎,明辨晰也。', + '六五:厥孚交如,信以发志也;威如之吉,易而无备也。', + '上九:大有上吉,自天佑也。', + ], + [ + '初六:谦谦君子,卑以自牧也。', + '六二:鸣谦贞吉,中心得也。', + '九三:劳谦君子,万民服也。', + '六四:无不利,撝谦,不违则也。', + '六五:利用侵伐,征不服也。', + '上六:鸣谦,志未得也,可用行师,征邑国也。', + ], + [ + '初六:初六鸣豫,志穷凶也。', + '六二:不终日,贞吉,以中正也。', + '六三:盱豫有悔,位不当也。', + '九四:由豫,大有得,志大行也。', + '六五:六五,贞疾,乘刚也;恒不死,中未亡也。', + '上六:冥豫在上,何可长也。', + ], + [ + '初九:官有渝,从正吉也;出门交有功,不失也。', + '六二:系小子,弗兼与也。', + '六三:系丈夫,志舍下也。', + '九四:随有获,其义凶也;有孚在道,明功也。', + '九五:孚于嘉吉,位正中也。', + '上六:拘系之,上穷也。', + ], + [ + '初六:干父之蛊,意承考也。', + '九二:干母之蛊,得中道也。', + '九三:干父之蛊,终无咎也。', + '六四:裕父之蛊,往未得也。', + '六五:干父用誉,承以德也。', + '上九:不事王侯,志可则也。', + ], + [ + '初九:咸临贞吉,志行正也。', + '九二:咸临吉无不利,未顺命也。', + '六三:甘临,位不当也,既忧之,咎不长也。', + '六四:至临无咎,位当也。', + '六五:大君之宜,行中之谓也。', + '上六:敦临之吉,志在内也。', + ], + [ + '初六:初六童观,小人道也。', + '六二:窥观女贞,亦可丑也。', + '六三:观我生进退,未失道也。', + '六四:观国之光,尚宾也。', + '九五:观我生,观民也。', + '上九:观其生,志未平也。', + ], + [ + '初九:屦校灭趾,不行也。', + '六二:噬肤灭鼻,乘刚也。', + '六三:遇毒,位不当也。', + '九四:利艰贞吉,未光也。', + '六五:贞厉无咎,得当也。', + '上九:何校灭耳,聪不明也。', + ], + [ + '初九:舍车而徒,义弗乘也。', + '六二:贲其须,与上兴也。', + '九三:永贞之吉,终莫之陵也。', + '六四:六四当位,疑也;匪寇婚媾,终无尤也。', + '六五:六五之吉,有喜也。', + '上九:白贲无咎,上得志也。', + ], + [ + '初六:剥床以足,以灭下也。', + '六二:剥床以辨,未有与也。', + '六三:剥之无咎,失上下也。', + '六四:剥床以肤,切近灾也。', + '六五:以宫人宠,终无尤也。', + '上九:君子得舆,民所载也,小人剥庐,终不可用也。', + ], + [ + '初九:不远之复,以修身也。', + '六二:休复之吉,以下仁也。', + '六三:频复之厉,义无咎也。', + '六四:中行独复,以从道也。', + '六五:敦复无悔,中以自考也。', + '上六:迷复之凶,反君道也。', + ], + [ + '初九:无妄之往,得志也。', + '六二:不耕获,未富也。', + '六三:行人得牛,邑人灾也。', + '九四:可贞无咎,固有之也。', + '九五:无妄之药,不可试也。', + '上九:无妄之行,穷之灾也。', + ], + [ + '初九:有厉利巳,不犯灾也。', + '九二:舆说輹,中无尤也。', + '九三:利有攸往,上合志也。', + '六四:六四元吉,有喜也。', + '六五:六五之吉,有庆也。', + '上九:何天之衢,道大行也。', + ], + [ + '初九:观我朵颐,亦不足贵也。', + '六二:六二征凶,行失类也。', + '六三:十年勿用,道大悖也。', + '六四:颠颐之吉,上施光也。', + '六五:居贞之吉,顺以从上也。', + '上九:由颐厉吉,大有庆也。', + ], + [ + '初六:藉用白茅,柔在下也。', + '九二:老夫女妻,过以相与也。', + '九三:栋桡之凶,不可以有辅也。', + '九四:栋隆之吉,不桡乎下也。', + '九五:枯杨生华,何可久也;老妇士夫,亦可丑也。', + '上六:过涉之凶,不可咎也。', + ], + [ + '初六:习坎入坎,失道凶也。', + '九二:求小得,未出中也。', + '六三:来之坎坎,终无功也。', + '六四:樽酒簋贰,刚柔际也。', + '九五:坎不盈,中未大也。', + '上六:上六失道,凶三岁也。', + ], + [ + '初九:履错之敬,以辟咎也。', + '六二:黄离元吉,得中道也。', + '九三:日昃之离,何可久也。', + '九四:突如其来如,无所容也。', + '六五:六五之吉,离王公也。', + '上九:王用出征,以正邦也。', + ], + [ + '初六:咸其拇,志在外也。', + '六二:虽凶居吉,顺不害也。', + '九三:咸其股,亦不处也,志在随人,所执下也。', + '九四:贞吉悔亡,未感害也;憧憧往来,未光大也。', + '九五:咸其脢,志末也。', + '上六:咸其辅颊舌,滕口说也。', + ], + [ + '初六:浚恒之凶,始求深也。', + '九二:九二悔亡,能久中也。', + '九三:不恒其德,无所容也。', + '九四:久非其位,安得禽也。', + '六五:妇人贞吉,从一而终也,夫子制义,从妇凶也。', + '上六:振恒在上,大无功也。', + ], + [ + '初六:遁尾之厉,不往何灾也。', + '六二:执用黄牛,固志也。', + '九三:系遁之厉,有疾惫也;畜臣妾吉,不可大事也。', + '九四:君子好遁,小人否也。', + '九五:嘉遁贞吉,以正志也。', + '上九:肥遁,无不利,无所疑也。', + ], + [ + '初九:壮于趾,其孚穷也。', + '九二:九二贞吉,以中也。', + '九三:小人用壮,君子罔也。', + '九四:藩决不羸,尚往也。', + '六五:丧羊于易,位不当也。', + '上六:不能退,不能遂,不详也。艰则吉,咎不长也。', + ], + [ + '初六:晋如摧如,独行正也;裕无咎,未受命也。', + '六二:受兹介福,以中正也。', + '六三:众允之,志上行也。', + '九四:鼫鼠贞厉,位不当也。', + '六五:失得勿恤,往有庆也。', + '上九:维用伐邑,道未光也。', + ], + [ + '初九:君子于行,义不食也。', + '六二:六二之吉,顺以则也。', + '九三:南狩之志,乃大得也。', + '六四:入于左腹,获心意也。', + '六五:箕子之贞,明不可息也。', + '上六:初登于天,照四国也;后入于地,失则也。', + ], + [ + '初九:闲有家,志未变也。', + '六二:六二之吉,顺以巽也。', + '九三:家人嗃嗃,未失也;妇子嘻嘻,失家节也。', + '六四:富家大吉,顺在位也。', + '九五:王假有家,交相爱也。', + '上九:威如之吉,反身之谓也。', + ], + [ + '初九:见恶人,以辟咎也。', + '九二:遇主于巷,未失道也。', + '六三:见舆曳,位不当也,无初有终,遇刚也。', + '九四:交孚无咎,志行也。', + '六五:厥宗噬肤,往有庆也。', + '上九:遇雨之吉,群疑亡也。', + ], + [ + '初六:往蹇来誉,宜待也。', + '六二:王臣蹇蹇,终无尤也。', + '九三:往蹇来反,内喜之也。', + '六四:往蹇来连,当位实也。', + '九五:大蹇朋来,以中节也。', + '上六:往蹇来硕,志在内也;利见大人,以从贵也。', + ], + [ + '初六:刚柔之际,义无咎也。', + '九二:九二贞吉,得中道也。', + '六三:负且乘,亦可丑也,自我致戎,又谁咎也。', + '九四:解而拇,未当位也。', + '六五:君子有解,小人退也。', + '上六:公用射隼,以解悖也。', + ], + [ + '初九:巳事遄往,尚合志也。', + '九二:九二利贞,中以为志也。', + '六三:一人行,三则疑也。', + '六四:损其疾,亦可喜也。', + '六五:六五元吉,自上佑也。', + '上九:弗损益之,大得志也。', + ], + [ + '初九:元吉,无咎,下不厚事也。', + '六二:或益之,自外来也。', + '六三:益用凶事,固有之也。', + '六四:告公从,以益志也。', + '九五:有孚惠心,勿问之矣;惠我德,大得志也。', + '上九:莫益之,偏辞也;或击之,自外来也。', + ], + [ + '初九:不胜而往,咎也。', + '九二:有戎勿恤,得中道也。', + '九三:君子夬夬,终无咎也。', + '九四:其行次且,位不当也,闻言不信,聪不明也。', + '九五:中行无咎,中未光也。', + '上六:无号之凶,终不可长也。', + ], + [ + '初六:系于金柅,柔道牵也。', + '九二:包有鱼,义不及宾也。', + '九三:其行次且,行未牵也。', + '九四:无鱼之凶,远民也。', + '九五:九五含章,中正也,有陨自天,志不舍命也。', + '上九:姤其角,上穷吝也。', + ], + [ + '初六:乃乱乃萃,其志乱也。', + '六二:引吉无咎,中未变也。', + '六三:往无咎,上巽也。', + '九四:大吉无咎,位不当也。', + '九五:萃有位,志未光也。', + '上六:赍咨涕洟,未安上也。', + ], + [ + '初六:允升大吉,上合志也。', + '九二:九二之孚,有喜也。', + '九三:升虚邑,无所疑也。', + '六四:王用亨于岐山,顺事也。', + '六五:贞吉升阶,大得志也。', + '上六:冥升在上,消不富也。', + ], + [ + '初六:入于幽谷,幽不明也。', + '九二:困于酒食,中有庆也。', + '六三:据于蒺藜,乘刚也。入于其宫,不见其妻,不祥也。', + '九四:来徐徐,志在下也。虽不当位,有与也。', + '九五:劓刖,志未得也;乃徐有说,以中直也;利用祭祀,受福也。', + '上六:困于葛藟,未当也;动悔有悔,吉行也。', + ], + [ + '初六:井泥不食,下也;旧井无禽,时舍也。', + '九二:井谷射鲋,无与也。', + '九三:井渫不食,行恻也;求王明,受福也。', + '六四:井甃无咎,修井也。', + '九五:寒泉之食,中正也。', + '上六:元吉在上,大成也。', + ], + [ + '初九:巩用黄牛,不可以有为也。', + '六二:巳日革之,行有嘉也。', + '九三:革言三就,又何之矣。', + '九四:改命之吉,信志也。', + '九五:大人虎变,其文炳也。', + '上六:君子豹变,其文蔚也;小人革面,顺以从君也。', + ], + [ + '初六:鼎颠趾,未悖也。利出否,以从贵也。', + '九二:鼎有实,慎所之也;我仇有疾,终无尤也。', + '九三:鼎耳革,失其义也。', + '九四:覆公餗,信如何也。', + '六五:鼎黄耳,中以为实也。', + '上九:玉铉在上,刚柔节也。', + ], + [ + '初九:震来虩虩,恐致福也;笑言哑哑,后有则也。', + '六二:震来厉,乘刚也。', + '六三:震苏苏,位不当也。', + '九四:震遂泥,未光也。', + '六五:震往来厉,危行也,其事在中,大无丧也。', + '上六:震索索,中未得也;虽凶无咎,畏邻戒也。', + ], + [ + '初六:艮其趾,未失正也。', + '六二:不拯其随,未退听也。', + '九三:艮其限,危薰心也。', + '六四:艮其身,止诸躬也。', + '六五:艮其辅,以中正也。', + '上九:敦艮之吉,以厚终也。', + ], + [ + '初六:小子之厉,义无咎也。', + '六二:饮食衎衎,不素饱也。', + '九三:夫征不复,离群丑也;妇孕不育,失其道也;利用御寇,顺相保也。', + '六四:或得其桷,顺以巽也。', + '九五:终莫之胜,吉,得所愿也。', + '上九:其羽可用为仪,吉,不可乱也。', + ], + [ + '初九:归妹以娣,以恒也;跛能履,吉,相承也。', + '九二:利幽人之贞,未变常也。', + '六三:归妹以须,未当也。', + '九四:愆期之志,有待而行也。', + '六五:帝乙归妹,不如其娣之袂良也,其位在中,以贵行也。', + '上六:上六无实,承虚筐也。', + ], + [ + '初九:虽旬无咎,过旬灾也。', + '六二:有孚发若,信以发志也。', + '九三:丰其沛,不可大事也;折其右肱,终不可用也。', + '九四:丰其蔀,位不当也;日中见斗,幽不明也;遇其夷主,吉行也。', + '六五:六五之吉,有庆也。', + '上六:丰其屋,天际翔也;窥其户,阒其无人,自藏也。', + ], + [ + '初六:旅琐琐,志穷灾也。', + '六二:得童仆贞,终无尤也。', + '九三:旅焚其次,亦以伤矣。以旅与下,其义丧也。', + '九四:旅于处,未得位也;得其资斧,心未快也。', + '六五:终以誉命,上逮也。', + '上九:以旅在上,其义焚也;丧牛于易,终莫之闻也。', + ], + [ + '初六:进退,志疑也;利武人之贞,志治也。', + '九二:纷若之吉,得中也。', + '九三:频巽之吝,志穷也。', + '六四:田获三品,有功也。', + '九五:九五之吉,位正中也。', + '上九:巽在床下,上穷也;丧其资斧,正乎凶也。', + ], + [ + '初九:和兑之吉,行未疑也。', + '九二:孚兑之吉,信志也。', + '六三:来兑之凶,位不当也。', + '九四:九四之喜,有庆也。', + '九五:孚于剥,位正当也。', + '上六:上六引兑,未光也。', + ], + [ + '初六:初六之吉,顺也。', + '九二:涣奔其机,得愿也。', + '六三:涣其躬,志在外也。', + '六四:涣其群,元吉,光大也。', + '九五:王居无咎,正位也。', + '上九:涣其血,远害也。', + ], + [ + '初九:不出户庭,知通塞也。', + '九二:不出门庭凶,失时极也。', + '六三:不节之嗟,又谁咎也。', + '六四:安节之亨,承上道也。', + '九五:甘节之吉,居位中也。', + '上六:苦节贞凶,其道穷也。', + ], + [ + '初九:初九虞吉,志未变也。', + '九二:其子和之,中心愿也。', + '六三:或鼓或罢,位不当也。', + '六四:马匹亡,绝类上也。', + '九五:有孚挛如,位正当也。', + '上九:翰音登于天,何可长也。', + ], + [ + '初六:飞鸟以凶,不可如何也。', + '六二:不及其君,臣不可过也。', + '九三:从或戕之,凶如何也。', + '九四:弗过遇之,位不当也;往厉必戒,终不可长也。', + '六五:密云不雨,巳上也。', + '上六:弗遇过之,巳亢也。', + ], + [ + '初九:曳其轮,义无咎也。', + '六二:七日得,以中道也。', + '九三:三年克之,惫也。', + '六四:终日戒,有所疑也。', + '九五:东邻杀牛,不如西邻之时也,实受其福,吉大来也。', + '上六:濡其首厉,何可久也。', + ], + [ + '初六:濡其尾,亦不知极也。', + '九二:九二贞吉,中以行正也。', + '六三:未济征凶,位不当也。', + '九四:贞吉悔亡,志行也。', + '六五:君子之光,其晖吉也。', + '上九:饮酒濡首,亦不知节也。', + ], +]; + +/** 取某卦某一爻的小象传。order 为卦序 1 至 64,position 自 1 起,与排盘一致。 */ +export function lineXiang(order, position) { + return XIANG_TEXTS[order - 1]?.[position - 1] ?? null; +} + +/** 取某卦六爻的小象传,自初爻至上爻。 */ +export function hexagramXiangTexts(order) { + return XIANG_TEXTS[order - 1] ?? null; +} diff --git a/plugins/weekbin/chinese-divination/miniapp/node/xiang.mjs b/plugins/weekbin/chinese-divination/miniapp/node/xiang.mjs new file mode 100644 index 0000000..5d6f4e3 --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/node/xiang.mjs @@ -0,0 +1,48 @@ +// @ts-check + +/** + * 取象:动爻的爻位、后天方位、五行旺相。 + * + * 应期的主语有两种:认出了事类时看「某某类神」的五行,没认出来时退回「用卦」—— + * 用卦就是所问之事本身,这是梅花易数的本分。 + */ + +/** + * 爻位之象。动爻所在的位置本身带信息。 + * @type {readonly {title: string, text: string}[]} + */ +export const LINE_POSITIONS = Object.freeze([ + Object.freeze({ title: '初爻 · 事之始', text: '事在萌芽,位未显。多主开端、起步、近身之事,宜布局而不宜论成败。' }), + Object.freeze({ title: '二爻 · 臣位近身', text: '居内卦之中,近于己身。事在自家可控范围,但受制于上,仍需仰望。' }), + Object.freeze({ title: '三爻 · 门户之交', text: '内卦之极、外卦之始,进退转换之关。多主变动、转折、去留之问。' }), + Object.freeze({ title: '四爻 · 近臣惧位', text: '居外卦之近,多惧、多惊扰,与人接触最密,也最易受他人牵动。' }), + Object.freeze({ title: '五爻 · 君位', text: '阳爻居君位,主尊长、贵人、上级与最终拍板者。事多以得贵人助力告终。' }), + Object.freeze({ title: '上爻 · 事之终', text: '事至终局,多主超然、退出、了结与尘埃落定。' }), +]); + +/** + * 五行之旺相之地,用以推应期。 + * 旺者当令之气,相者我生之地。月支与日支都按此取。 + */ +const SEASON_TABLE = Object.freeze({ + 木: { wang: '寅卯', xiang: '巳午' }, + 火: { wang: '巳午', xiang: '辰戌丑未' }, + 土: { wang: '辰戌丑未', xiang: '申酉' }, + 金: { wang: '申酉', xiang: '亥子丑' }, + 水: { wang: '亥子', xiang: '寅卯' }, +}); + +/** + * 应期:五行旺相之地所在的月与日。 + * @param {string} element 五行 + * @param {string} [subject] 主语,如「用卦坎」「财运类神」;缺省作「用卦」 + */ +export function responseTiming(element, subject = '用卦') { + const table = SEASON_TABLE[element]; + if (!table) return null; + return { + wang: table.wang, + xiang: table.xiang, + text: `${subject}属${element},旺在${table.wang},相在${table.xiang}。事情多在${table.wang}月或${table.wang}日见端倪,至${table.xiang}前后渐明。`, + }; +} diff --git a/plugins/weekbin/chinese-divination/miniapp/node/yao.mjs b/plugins/weekbin/chinese-divination/miniapp/node/yao.mjs new file mode 100644 index 0000000..e9a212e --- /dev/null +++ b/plugins/weekbin/chinese-divination/miniapp/node/yao.mjs @@ -0,0 +1,546 @@ +// @ts-check + +/** + * 《周易》六十四卦爻辞,共 384 条。 + * + * 卦辞讲一卦大势,爻辞讲「动在这一爻」的处境。梅花易数取动爻,动的那个位置 + * 遇上的正是这里的一条,所以整份备全,取用时才做取舍。 + * + * 每条为「爻题:爻辞」。爻题即传统记法(初九、六二……上六),阳爻称九、阴爻 + * 称六,这一层由测试逐条比对卦象校验,写错一位就会报红。 + * + * 底本:太极之巅《易经·原文》六十四卦(通行本标点),逐条与卦客网《周易》简体版 + * 对校——380 条两源字序全合,另 4 条该源有截断,以本底为准;繁简转换与改字断句的 + * 订正共 16 处,已落在下方数据里。异体、通假一仍其旧(如「繻」「蔀」「罔」「说」通 + * 「脱」)。其中噬嗑六五一处的爻题,两个来源都作「九五」,而该爻在卦象上是阴, + * 已按卦象订正。 + */ + +/** + * 每卦六条,自初爻至上爻;下标 0 是初爻。顺序与 hexagrams.mjs 的六十四卦序一致。 + * @type {readonly (readonly [string, string, string, string, string, string])[]} + */ +const YAO_TEXTS = [ + [ + '初九:潜龙勿用。', + '九二:见龙在田,利见大人。', + '九三:君子终日乾乾,夕惕若,厉,无咎。', + '九四:或跃在渊,无咎。', + '九五:飞龙在天,利见大人。', + '上九:亢龙有悔。', + ], + [ + '初六:履霜,坚冰至。', + '六二:直方大,不习无不利。', + '六三:含章可贞,或从王事,无成有终。', + '六四:括囊,无咎无誉。', + '六五:黄裳,元吉。', + '上六:龙战于野,其血玄黄。', + ], + [ + '初九:磐桓,利居贞,利建侯。', + '六二:屯如邅如,乘马班如,匪寇婚媾,女子贞不字,十年乃字。', + '六三:即鹿无虞,惟入于林中。君子几不如舍。往吝。', + '六四:乘马班如,求婚媾,往吉无不利。', + '九五:屯其膏,小贞吉,大贞凶。', + '上六:乘马班如,泣血涟如。', + ], + [ + '初六:发蒙,利用刑人,用说桎梏,以往吝。', + '九二:包蒙吉,纳妇吉,子克家。', + '六三:勿用取女,见金夫,不有躬,无攸利。', + '六四:困蒙,吝。', + '六五:童蒙,吉。', + '上九:击蒙,不利为寇,利御寇。', + ], + [ + '初九:需于郊,利用恒,无咎。', + '九二:需于沙,小有言,终吉。', + '九三:需于泥,致寇至。', + '六四:需于血,出自穴。', + '九五:需于酒食,贞吉。', + '上六:入于穴,有不速之客三人来,敬之,终吉。', + ], + [ + '初六:不永所事,小有言,终吉。', + '九二:不克讼,归而逋其邑,人三百户,无眚。', + '六三:食旧德,贞厉,终吉,或从王事,无成。', + '九四:不克讼,复即命渝,安贞吉。', + '九五:讼,元吉。', + '上九:或锡之鞶带,终朝三褫之。', + ], + [ + '初六:师出以律,否臧,凶。', + '九二:在师中吉,无咎,王三锡命。', + '六三:师或舆尸,凶。', + '六四:师左次,无咎。', + '六五:田有禽,利执言,无咎。长子帅师,弟子舆尸,贞凶。', + '上六:大君有命,开国承家,小人勿用。', + ], + [ + '初六:有孚,比之无咎。有孚盈缶,终来,有它吉。', + '六二:比之自内,贞吉。', + '六三:比之匪人。', + '六四:外比之,贞吉。', + '九五:显比,王用三驱,失前禽,邑人不诫,吉。', + '上六:比之无首,凶。', + ], + [ + '初九:复自道,何其咎,吉。', + '九二:牵复,吉。', + '九三:舆说輹,夫妻反目。', + '六四:有孚,血去惕出,无咎。', + '九五:有孚挛如,富以其邻。', + '上九:既雨既处,尚德载,妇贞厉。月几望,君子征凶。', + ], + [ + '初九:素履,往,无咎。', + '九二:履道坦坦,幽人贞吉。', + '六三:眇能视,跛能履,履虎尾,咥人,凶。武人为于大君。', + '九四:履虎尾,愬愬,终吉。', + '九五:夬履,贞厉。', + '上九:视履,考祥其旋,元吉。', + ], + [ + '初九:拔茅茹,以其汇,征吉。', + '九二:包荒,用冯河,不遐遗,朋亡,得尚于中行。', + '九三:无平不陂,无往不复,艰贞无咎。勿恤其孚,于食有福。', + '六四:翩翩,不富以其邻,不戒以孚。', + '六五:帝乙归妹,以祉元吉。', + '上六:城复于隍,勿用师。自邑告命,贞吝。', + ], + [ + '初六:拔茅茹,以其汇,贞吉,亨。', + '六二:包承,小人吉,大人否亨。', + '六三:包羞。', + '九四:有命无咎,畴离祉。', + '九五:休否,大人吉,其亡其亡,系于苞桑。', + '上九:倾否,先否后喜。', + ], + [ + '初九:同人于门,无咎。', + '六二:同人于宗,吝。', + '九三:伏戎于莽,升其高陵,三岁不兴。', + '九四:乘其墉,弗克攻,吉。', + '九五:同人,先号啕而后笑,大师克,相遇。', + '上九:同人于郊,无悔。', + ], + [ + '初九:无交害,匪咎,艰则无咎。', + '九二:大车以载,有攸往,无咎。', + '九三:公用亨于天子,小人弗克。', + '九四:匪其尫,无咎。', + '六五:厥孚交如,威如,吉。', + '上九:自天佑之,吉,无不利。', + ], + [ + '初六:谦谦君子,用涉大川,吉。', + '六二:鸣谦,贞吉。', + '九三:劳谦,君子有终,吉。', + '六四:无不利,撝谦。', + '六五:不富以其邻,利用侵伐,无不利。', + '上六:鸣谦,利用行师,征邑国。', + ], + [ + '初六:鸣豫,凶。', + '六二:介于石,不终日,贞吉。', + '六三:盱豫,悔,迟有悔。', + '九四:由豫,大有得。勿疑,朋盍簪。', + '六五:贞疾,恒不死。', + '上六:冥豫,成有渝,无咎。', + ], + [ + '初九:官有渝,贞吉。出门交有功。', + '六二:系小子,失丈夫。', + '六三:系丈夫,失小子。随有求得,利居贞。', + '九四:随有获,贞凶。有孚,在道以明,何咎?', + '九五:孚于嘉,吉。', + '上六:拘系之,乃从维之,王用亨于西山。', + ], + [ + '初六:干父之蛊,有子,考无咎,厉,终吉。', + '九二:干母之蛊,不可贞。', + '九三:干父之蛊,小有悔,无大咎。', + '六四:裕父之蛊,往见吝。', + '六五:干父之蛊,用誉。', + '上九:不事王侯,高尚其事。', + ], + [ + '初九:咸临,贞吉。', + '九二:咸临,吉,无不利。', + '六三:甘临,无攸利,既忧之,无咎。', + '六四:至临,无咎。', + '六五:知临,大君之宜,吉。', + '上六:敦临,吉,无咎。', + ], + [ + '初六:童观,小人无咎,君子吝。', + '六二:窥观,利女贞。', + '六三:观我生,进退。', + '六四:观国之光,利用宾于王。', + '九五:观我生,君子无咎。', + '上九:观其生,君子无咎。', + ], + [ + '初九:屦校灭趾,无咎。', + '六二:噬肤,灭鼻,无咎。', + '六三:噬腊肉,遇毒,小吝,无咎。', + '九四:噬乾胏,得金矢,利艰贞,吉。', + '六五:噬乾肉,得黄金,贞厉,无咎。', + '上九:何校灭耳,凶。', + ], + [ + '初九:贲其趾,舍车而徒。', + '六二:贲其须。', + '九三:贲如濡如,永贞吉。', + '六四:贲如皤如,白马翰如,匪寇婚媾。', + '六五:贲于丘园,束帛戋戋。吝,终吉。', + '上九:白贲,无咎。', + ], + [ + '初六:剥床以足,蔑贞,凶。', + '六二:剥床以辨,蔑贞,凶。', + '六三:剥之无咎。', + '六四:剥床以肤,凶。', + '六五:贯鱼,以宫人宠,无不利。', + '上九:硕果不食,君子得舆,小人剥庐。', + ], + [ + '初九:不远复,无祗悔,元吉。', + '六二:休复,吉。', + '六三:频复,厉,无咎。', + '六四:中行独复。', + '六五:敦复,无悔。', + '上六:迷复,凶,有灾眚,用行师,终有大败。以其国君,凶。至于十年不克征。', + ], + [ + '初九:无妄,往吉。', + '六二:不耕获,不菑畲,则利有攸往。', + '六三:无妄之灾,或系之牛,行人之得,邑人之灾。', + '九四:可贞,无咎。', + '九五:无妄之疾,勿药有喜。', + '上九:无妄行,有眚,无攸利。', + ], + [ + '初九:有厉,利巳。', + '九二:舆说輹。', + '九三:良马逐,利艰贞。曰闲舆卫,利有攸往。', + '六四:童牛之牿,元吉。', + '六五:豮豕之牙,吉。', + '上九:何天之衢,亨。', + ], + [ + '初九:舍尔灵龟,观我朵颐,凶。', + '六二:颠颐,拂经,于丘颐,征凶。', + '六三:拂颐,贞凶。十年勿用,无攸利。', + '六四:颠颐,吉,虎视眈眈,其欲逐逐,无咎。', + '六五:拂经,居贞吉,不可涉大川。', + '上九:由颐,厉吉,利涉大川。', + ], + [ + '初六:藉用白茅,无咎。', + '九二:枯杨生稊,老夫得其女妻,无不利。', + '九三:栋桡,凶。', + '九四:栋隆,吉,有它吝。', + '九五:枯杨生华,老妇得其士夫,无咎无誉。', + '上六:过涉灭顶,凶,无咎。', + ], + [ + '初六:习坎,入于坎窞,凶。', + '九二:坎,有险,求小得。', + '六三:来之坎坎,险且枕,入于坎窞,勿用。', + '六四:樽酒,簋贰,用缶,纳约自牖,终无咎。', + '九五:坎不盈,祗既平,无咎。', + '上六:系用徽纆,寘于丛棘,三岁不得,凶。', + ], + [ + '初九:履错然,敬之,无咎。', + '六二:黄离,元吉。', + '九三:日昃之离,不鼓缶而歌,则大耋之嗟,凶。', + '九四:突如其来如,焚如,死如,弃如。', + '六五:出涕沱若,戚嗟若,吉。', + '上九:王用出征,有嘉折首,获匪其丑,无咎。', + ], + [ + '初六:咸其拇。', + '六二:咸其腓,凶,居吉。', + '九三:咸其股,执其随,往吝。', + '九四:贞吉,悔亡,憧憧往来,朋从尔思。', + '九五:咸其脢,无悔。', + '上六:咸其辅颊舌。', + ], + [ + '初六:浚恒,贞凶,无攸利。', + '九二:悔亡。', + '九三:不恒其德,或承之羞,贞吝。', + '九四:田无禽。', + '六五:恒其德贞,妇人吉,夫子凶。', + '上六:振恒,凶。', + ], + [ + '初六:遁尾,厉。勿用有攸往。', + '六二:执之用黄牛之革,莫之胜说。', + '九三:系遁,有疾厉,畜臣妾吉。', + '九四:好遁,君子吉,小人否。', + '九五:嘉遁,贞吉。', + '上九:肥遁,无不利。', + ], + [ + '初九:壮于趾,征凶,有孚。', + '九二:贞吉。', + '九三:小人用壮,君子用罔。贞厉,羝羊触藩,羸其角。', + '九四:贞吉,悔亡,藩决不羸,壮于大舆之輹。', + '六五:丧羊于易,无悔。', + '上六:羝羊触藩,不能退,不能遂,无攸利,艰则吉。', + ], + [ + '初六:晋如摧如,贞吉。罔孚,裕无咎。', + '六二:晋如愁如,贞吉。受兹介福,于其王母。', + '六三:众允,悔亡。', + '九四:晋如鼫鼠,贞厉。', + '六五:悔亡,失得勿恤。往吉,无不利。', + '上九:晋其角,维用伐邑。厉吉无咎,贞吝。', + ], + [ + '初九:明夷于飞,垂其翼,君子于行,三日不食。有攸往,主人有言。', + '六二:明夷,夷于左股,用拯马壮吉。', + '九三:明夷于南狩,得其大首,不可疾贞。', + '六四:入于左腹,获明夷之心,于出门庭。', + '六五:箕子之明夷,利贞。', + '上六:不明晦,初登于天,后入于地。', + ], + [ + '初九:闲有家,悔亡。', + '六二:无攸遂,在中馈,贞吉。', + '九三:家人嗃嗃,悔厉吉,妇子嘻嘻,终吝。', + '六四:富家,大吉。', + '九五:王假有家,勿恤,吉。', + '上九:有孚,威如,终吉。', + ], + [ + '初九:悔亡,丧马勿逐,自复。见恶人,无咎。', + '九二:遇主于巷,无咎。', + '六三:见舆曳,其牛掣,其人天且劓,无初有终。', + '九四:睽孤,遇元夫。交孚,厉无咎。', + '六五:悔亡。厥宗噬肤,往何咎。', + '上九:睽孤,见豕负涂,载鬼一车,先张之弧,后说之弧,匪寇婚媾。往,遇雨则吉。', + ], + [ + '初六:往蹇,来誉。', + '六二:王臣蹇蹇,匪躬之故。', + '九三:往蹇,来反。', + '六四:往蹇,来连。', + '九五:大蹇,朋来。', + '上六:往蹇,来硕,吉,利见大人。', + ], + [ + '初六:无咎。', + '九二:田获三狐,得黄矢,贞吉。', + '六三:负且乘,致寇至,贞吝。', + '九四:解而拇,朋至斯孚。', + '六五:君子维有解,吉,有孚于小人。', + '上六:公用射隼于高墉之上,获之,无不利。', + ], + [ + '初九:已事遄往,无咎,酌损之。', + '九二:利贞,征凶,弗损益之。', + '六三:三人行,则损一人,一人行,则得其友。', + '六四:损其疾,使遄有喜,无咎。', + '六五:或益之,十朋之龟弗克违,元吉。', + '上九:弗损益之,无咎,贞吉,利有攸往,得臣无家。', + ], + [ + '初九:利用为大作,元吉,无咎。', + '六二:或益之,十朋之龟弗克违,永贞吉。王用享于帝,吉。', + '六三:益之用凶事,无咎。有孚中行,告公用圭。', + '六四:中行,告公从,利用为依迁国。', + '九五:有孚惠心,勿问元吉。有孚惠我德。', + '上九:莫益之,或击之,立心勿恒,凶。', + ], + [ + '初九:壮于前趾,往不胜为咎。', + '九二:惕号,莫夜有戎,勿恤。', + '九三:壮于頄,有凶。君子夬夬,独行,遇雨若濡。有愠,无咎。', + '九四:臀无肤,其行次且,牵羊悔亡,闻言不信。', + '九五:苋陆夬夬,中行无咎。', + '上六:无号,终有凶。', + ], + [ + '初六:系于金柅,贞吉。有攸往,见凶。羸豕孚蹢躅。', + '九二:包有鱼,无咎,不利宾。', + '九三:臀无肤,其行次且,厉,无大咎。', + '九四:包无鱼,起凶。', + '九五:以杞包瓜,含章,有陨自天。', + '上九:姤其角,吝,无咎。', + ], + [ + '初六:有孚不终,乃乱乃萃,若号。一握为笑,勿恤,往无咎。', + '六二:引吉,无咎,孚乃利用禴。', + '六三:萃如嗟如,无攸利。往无咎,小吝。', + '九四:大吉,无咎。', + '九五:萃有位,无咎,匪孚。元永贞,悔亡。', + '上六:赍咨涕洟,无咎。', + ], + [ + '初六:允升,大吉。', + '九二:孚乃利用禴,无咎。', + '九三:升虚邑。', + '六四:王用亨于岐山,吉,无咎。', + '六五:贞吉,升阶。', + '上六:冥升,利于不息之贞。', + ], + [ + '初六:臀困于株木,入于幽谷,三岁不觌。', + '九二:困于酒食,朱绂方来,利用享祀,征凶,无咎。', + '六三:困于石,据于蒺蔾,入于其宫,不见其妻,凶。', + '九四:来徐徐,困于金车。吝,有终。', + '九五:劓刖,困于赤绂,乃徐有说。利用祭祀。', + '上六:困于葛藟,于臲卼,曰动悔有悔,征吉。', + ], + [ + '初六:井泥不食,旧井无禽。', + '九二:井谷射鲋,瓮敝漏。', + '九三:井渫不食,为我心恻,可用汲。王明,并受其福。', + '六四:井甃无咎。', + '九五:井冽,寒泉食。', + '上六:井收勿幕,有孚元吉。', + ], + [ + '初九:巩用黄牛之革。', + '六二:巳日乃革之,征吉,无咎。', + '九三:征凶,贞厉,革言三就,有孚。', + '九四:悔亡,有孚,改命吉。', + '九五:大人虎变,未占有孚。', + '上六:君子豹变,小人革面,征凶,居贞吉。', + ], + [ + '初六:鼎颠趾,利出否,得妾以其子,无咎。', + '九二:鼎有实,我仇有疾,不我能即,吉。', + '九三:鼎耳革,其行塞,雉膏不食。方雨亏悔,终吉。', + '九四:鼎折足,覆公餗,其形渥,凶。', + '六五:鼎黄耳金铉,利贞。', + '上九:鼎玉铉,大吉,无不利。', + ], + [ + '初九:震来虩虩,后笑言哑哑,吉。', + '六二:震来厉,亿丧贝,跻于九陵。勿逐,七日得。', + '六三:震苏苏,震行无眚。', + '九四:震遂泥。', + '六五:震往来厉,亿无丧,有事。', + '上六:震索索,视矍矍,征凶。震不于其躬,于其邻,无咎。婚媾有言。', + ], + [ + '初六:艮其趾,无咎,利永贞。', + '六二:艮其腓,不拯其随,其心不快。', + '九三:艮其限,列其夤,厉薰心。', + '六四:艮其身,无咎。', + '六五:艮其辅,言有序,悔亡。', + '上九:敦艮,吉。', + ], + [ + '初六:鸿渐于干,小子厉,有言,无咎。', + '六二:鸿渐于磐,饮食衎衎,吉。', + '九三:鸿渐于陆,夫征不复,妇孕不育,凶,利御寇。', + '六四:鸿渐于木,或得其桷,无咎。', + '九五:鸿渐于陵,妇三岁不孕,终莫之胜,吉。', + '上九:鸿渐于陆,其羽可用为仪,吉。', + ], + [ + '初九:归妹以娣,跛能履,征吉。', + '九二:眇能视,利幽人之贞。', + '六三:归妹以须,反归以娣。', + '九四:归妹愆期,迟归有时。', + '六五:帝乙归妹,其君之袂,不如其娣之袂良。月几望,吉。', + '上六:女承筐无实,士刲羊无血,无攸利。', + ], + [ + '初九:遇其配主,虽旬无咎,往有尚。', + '六二:丰其蔀,日中见斗。往得疑疾,有孚发若,吉。', + '九三:丰其沛,日中见沬。折其右肱,无咎。', + '九四:丰其蔀,日中见斗,遇其夷主,吉。', + '六五:来章,有庆誉,吉。', + '上六:丰其屋,蔀其家,窥其户,阒其无人,三岁不觌,凶。', + ], + [ + '初六:旅琐琐,斯其所取灾。', + '六二:旅即次,怀其资,得童仆贞。', + '九三:旅焚其次,丧其童仆,贞厉。', + '九四:旅于处,得其资斧,我心不快。', + '六五:射雉,一矢亡,终以誉命。', + '上九:鸟焚其巢,旅人先笑后号啕,丧牛于易,凶。', + ], + [ + '初六:进退,利武人之贞。', + '九二:巽在床下,用史巫纷若,吉,无咎。', + '九三:频巽,吝。', + '六四:悔亡,田获三品。', + '九五:贞吉,悔亡,无不利。无初有终,先庚三日,后庚三日,吉。', + '上九:巽在床下,丧其资斧,贞凶。', + ], + [ + '初九:和兑,吉。', + '九二:孚兑,吉,悔亡。', + '六三:来兑,凶。', + '九四:商兑未宁,介疾有喜。', + '九五:孚于剥,有厉。', + '上六:引兑。', + ], + [ + '初六:用拯马壮,吉。', + '九二:涣奔其机,悔亡。', + '六三:涣其躬,无悔。', + '六四:涣其群,元吉,涣有丘,匪夷所思。', + '九五:涣汗其大号,涣王居,无咎。', + '上九:涣其血,去逖出,无咎。', + ], + [ + '初九:不出户庭,无咎。', + '九二:不出门庭,凶。', + '六三:不节若,则嗟若,无咎。', + '六四:安节,亨。', + '九五:甘节,吉,往有尚。', + '上六:苦节,贞凶,悔亡。', + ], + [ + '初九:虞吉,有它不燕。', + '九二:鸣鹤在阴,其子和之;我有好爵,吾与尔靡之。', + '六三:得敌,或鼓或罢,或泣或歌。', + '六四:月几望,马匹亡,无咎。', + '九五:有孚挛如,无咎。', + '上九:翰音登于天,贞凶。', + ], + [ + '初六:飞鸟以凶。', + '六二:过其祖,遇其妣,不及其君,遇其臣,无咎。', + '九三:弗过防之,从或戕之,凶。', + '九四:无咎,弗过遇之,往厉必戒,勿用永贞。', + '六五:密云不雨,自我西郊,公弋取彼在穴。', + '上六:弗遇过之,飞鸟离之,凶,是谓灾眚。', + ], + [ + '初九:曳其轮,濡其尾,无咎。', + '六二:妇丧其茀,勿逐,七日得。', + '九三:高宗伐鬼方,三年克之,小人勿用。', + '六四:繻有衣袽,终日戒。', + '九五:东邻杀牛,不如西邻之禴祭,实受其福。', + '上六:濡其首,厉。', + ], + [ + '初六:濡其尾,吝。', + '九二:曳其轮,贞吉。', + '六三:未济,征凶,利涉大川。', + '九四:贞吉,悔亡,震用伐鬼方,三年有赏于大国。', + '六五:贞吉无悔,君子之光,有孚,吉。', + '上九:有孚于饮酒,无咎。濡其首,有孚,失是。', + ], +]; + +/** 取某卦某一爻的爻辞原文。order 为卦序 1 至 64,position 自 1 起,与排盘一致。 */ +export function lineText(order, position) { + return YAO_TEXTS[order - 1]?.[position - 1] ?? null; +} + +/** 取某卦六爻的爻辞,自初爻至上爻。 */ +export function hexagramYaoTexts(order) { + return YAO_TEXTS[order - 1] ?? null; +} diff --git a/plugins/weekbin/chinese-divination/package.json b/plugins/weekbin/chinese-divination/package.json new file mode 100644 index 0000000..aab6b8d --- /dev/null +++ b/plugins/weekbin/chinese-divination/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} diff --git a/plugins/weekbin/chinese-divination/servers.mcp.json b/plugins/weekbin/chinese-divination/servers.mcp.json new file mode 100644 index 0000000..a332e6e --- /dev/null +++ b/plugins/weekbin/chinese-divination/servers.mcp.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "mcpServers": { + "divination": { + "type": "streamable-http", + "url": "http://127.0.0.1:9/mcp/divination", + "headers": {}, + "description": "梅花易数起卦与解卦:起卦解读、六十四卦检索、今日干支历法。", + "timeout": 30000 + } + } +} diff --git a/plugins/weekbin/chinese-divination/skills/divination/SKILL.md b/plugins/weekbin/chinese-divination/skills/divination/SKILL.md new file mode 100644 index 0000000..fe8cbce --- /dev/null +++ b/plugins/weekbin/chinese-divination/skills/divination/SKILL.md @@ -0,0 +1,57 @@ +--- +name: divination +description: 用梅花易数起卦解卦。当用户要占卜、问吉凶、看运势、问某事该做不该做(换工作、要不要买、能不能成),或要查六十四卦辞义、今日干支黄历时使用。 +--- + +# 灵签易占 · 梅花易数 + +通过本插件的 MCP 端点起卦。卦象由传统占卜法推演,**你负责把它讲成人话**。 + +## 何时起卦,何时不起 + +起卦:用户问具体某事的吉凶去留——「这工作该不该跳」「这笔投资能不能赚」「他会不会来找我」「下个月面试能过吗」。 + +不起卦:用户只是要卦辞释义(「谦卦什么意思」)或要今日历法(「今天什么日子」)——改用 `divination_hexagram_lookup` 或 `divination_almanac`,不要为此起新卦。 + +## 起卦前:先把这件事问清楚 + +**这是最容易做错的一步。** 用户抛来一句「帮我算算」,你直接 `topic` + `cast` 走完,卦确实起了, +解读也生成了——但那是套话。你不知道他在纠结什么、已经走到哪一步、最怕的是哪一头, +讲出来就只剩「宜行」「待时」这种谁都套得上的话,问财和问跳槽拿到同一段解释。 + +一次问清三件事,别挤牙膏: + +- **到底是什么事**。是「换不换工作」还是「接不接这家 offer」——看着同一类,处境差着一整套。 +- **已经走到哪一步**。还在犹豫、已经定了在找支持、还是已经做了在问补救,这三种要的东西不同。 +- **最怕的是哪一头**。怕做砸,还是怕一直拖。同一卦对这两种人该落到不同的话上。 + +他说得含糊就问;他说「就这样,你算吧」,先把你理解的那件事复述一遍,问他对不对。 +**宁可多问一句,不要拿一个空的所问去起卦**——那样得到的是一卦悬在半空、双方都接不上的解读。 + +## 起卦三步 + +1. **定所问**。把用户的话原样写进 `question`,别替他改写成好话。 +2. **定事类**。读完上面问清的事,从九类里挑一个填进 `topic`: + `wealth` 钱与买卖 · `career` 工作前程与升迁 · `love` 恋爱与暧昧 · `marriage` 结婚与配偶 · + `health` 病症与身体 · `study` 考试与证照 · `property` 房舍车契与搬迁 · + `dispute` 官司与纠纷 · `journey` 远行与丢东西。拿不准就**省略**——省略后由关键词表接手, + 实在认不出就按用卦算,这时要告诉用户「没落到已知事类,应期按用卦推」。 +3. **选起法**:`time` 现在/此刻(默认)· `daily` 今天 · `coins` 掷铜钱或随机 · `numbers` 给了两个数。 + +## 讲卦的规矩 + +- **照着末尾的【大白话】讲**。`divination_cast` 返回的正文最后那一段已经把体卦翻成「你」、 + 用卦翻成「那件事」、旺衰翻成「你此刻有多少劲」,是给用户听的版本。别用术语复述一遍。 +- **先讲所问,再讲卦**。「你问的是接不接这家 offer」优先于「涣卦上坎下巽」。 +- **吉凶是卦定的,不是你定的**。由体用生克与月令旺衰单独推定,不随问题变化;事类只改应期、 + 用神与取象。同一个卦问财和问婚,凶不会翻面。 +- **卦里没有的话就说没有**。别把「不宜涉大川」硬译成「不宜跳槽」,也别反过来为了鼓励用户而改断语的意思。 +- **不替用户做决定**。卦是让对方想清楚的提示,不是行动指令。 + +## 每次都要带的免责说明 + +转述任何卦象时都要带上这句,不得省略、不得改写: + +> 内容由 AI 生成,仅供娱乐,无实际预测功能。卦象不构成任何建议、预测或决策依据,不应作为医疗、法律、财务等重要决定的参考。 + +用户追问「准不准」时,如实说明:这是按传统规则推演的结果,不是预测,实际结果取决于当事人自己的判断与选择。 diff --git a/plugins/weekbin/chinese-divination/tests/divination.test.mjs b/plugins/weekbin/chinese-divination/tests/divination.test.mjs new file mode 100644 index 0000000..bec01c4 --- /dev/null +++ b/plugins/weekbin/chinese-divination/tests/divination.test.mjs @@ -0,0 +1,5856 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { request as httpRequest } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + HEXAGRAM_LIST, + hexagramByOrder, + invertedHexagram, + mutualHexagram, + oppositeHexagram, + hexagramByKey, +} from '../miniapp/node/hexagrams.mjs'; +import { + almanac, + dayPillar, + hourOffice, + hourPillar, + hourPillarByBranch, + jianchu, + monthPillar, + shujiu, + yearPillar, +} from '../miniapp/node/almanac.mjs'; +import { + buildReading, + castByCoins, + castByNumbers, + castByTime, + castDaily, + tossCoins, +} from '../miniapp/node/divination.mjs'; +import { ReadingStore, shouldRetryRename } from '../miniapp/node/store.mjs'; +import { responseTiming } from '../miniapp/node/xiang.mjs'; +import { hexagramYaoTexts, lineText } from '../miniapp/node/yao.mjs'; +import { hexagramXiangTexts, lineXiang } from '../miniapp/node/xiang-chuan.mjs'; +import { tuanText } from '../miniapp/node/tuan.mjs'; +import { TWELVE_MESSAGES, hexagramQi, monthQi } from '../miniapp/node/guaqi.mjs'; +import { detectTopic, godRelation, TOPIC_CLASSES } from '../miniapp/node/topics.mjs'; +import { generates, overcomes } from '../miniapp/node/divination.mjs'; +import { + start, + describeError, + hostnameFromHeader, + isLocalRequest, + isLoopbackHostname, +} from '../miniapp/node/server.mjs'; + +test('六十四卦齐全且唯一', () => { + assert.equal(HEXAGRAM_LIST.length, 64); + assert.equal(new Set(HEXAGRAM_LIST.map((item) => item.key)).size, 64); + assert.equal(new Set(HEXAGRAM_LIST.map((item) => item.name)).size, 64); + assert.deepEqual( + HEXAGRAM_LIST.map((item) => item.order), + Array.from({ length: 64 }, (_unused, index) => index + 1), + ); +}); + +test('卦名书写为上卦在前,爻象由上下卦推出', () => { + // 乾为天:上乾下乾 + assert.equal(hexagramByOrder(1).key, '111111'); + assert.equal(hexagramByOrder(1).name, '乾为天'); + // 地天泰:上坤下乾 + assert.equal(hexagramByOrder(11).key, '111000'); + // 天地否:上乾下坤 + assert.equal(hexagramByOrder(12).key, '000111'); + // 山水蒙:上艮下坎 + assert.equal(hexagramByOrder(4).key, '010001'); + // 水雷屯:上坎下震 + assert.equal(hexagramByOrder(3).key, '100010'); + // 火水未济:上离下坎 + assert.equal(hexagramByOrder(64).key, '010101'); + // 水火既济:上坎下离 + assert.equal(hexagramByOrder(63).key, '101010'); +}); + +test('错卦阴阳互错,综卦上下颠倒', () => { + for (const hexagram of HEXAGRAM_LIST) { + assert.equal( + oppositeHexagram(hexagram).key, + [...hexagram.key].map((line) => (line === '1' ? '0' : '1')).join(''), + ); + assert.equal(invertedHexagram(hexagram).key, [...hexagram.key].reverse().join('')); + } + // 地天泰的错卦即天地否 + assert.equal(oppositeHexagram(hexagramByOrder(11)).name, '天地否'); + // 水火既济的错卦是火水未济 + assert.equal(oppositeHexagram(hexagramByOrder(63)).name, '火水未济'); + // 综卦是自身的逆序 + assert.equal(invertedHexagram(hexagramByOrder(1)).name, '乾为天'); + assert.equal(invertedHexagram(hexagramByOrder(2)).name, '坤为地'); +}); + +test('互卦取二三四爻为下卦、三四五爻为上卦', () => { + // 乾为天全阳,互卦仍为乾 + assert.equal(mutualHexagram(hexagramByOrder(1)).name, '乾为天'); + // 水雷屯 100010 -> 下取 000(坤) 上取 001(艮) = 山地剥 + assert.equal(mutualHexagram(hexagramByOrder(3)).name, '山地剥'); + for (const hexagram of HEXAGRAM_LIST) { + const lines = hexagram.key.split(''); + assert.equal(mutualHexagram(hexagram).key, lines.slice(1, 4).join('') + lines.slice(2, 5).join('')); + } +}); + +test('干支纪日与传世万年历一致', () => { + assert.equal(dayPillar(2000, 1, 1).name, '戊午'); + assert.equal(dayPillar(2000, 1, 2).name, '己未'); + assert.equal(dayPillar(2000, 1, 11).name, '戊辰'); + // 连续两日必进一位,六十甲子循环 + assert.equal(dayPillar(2026, 9, 29).index, (dayPillar(2026, 9, 30).index + 59) % 60); + assert.equal(dayPillar(2026, 9, 30).index, (dayPillar(2026, 9, 29).index + 1) % 60); +}); + +test('年干支以立春为界', () => { + assert.equal(yearPillar(2026, 9, 29).name, '丙午'); + assert.equal(yearPillar(2026, 9, 29).zodiac, '马'); + assert.equal(yearPillar(2025, 12, 25).name, '乙巳'); + assert.equal(yearPillar(2026, 2, 3).name, '乙巳'); + assert.equal(yearPillar(2026, 2, 5).name, '丙午'); +}); + +test('月柱以节气为界,五虎遁起月干', () => { + assert.equal(monthPillar(2026, 9, 29).jie, '白露'); + assert.equal(monthPillar(2026, 9, 29).name, '丁酉'); + // 小寒之前仍属大雪之后的子月 + assert.equal(monthPillar(2026, 1, 2).jie, '大雪'); + assert.equal(monthPillar(2026, 1, 2).name, '戊子'); + assert.equal(monthPillar(2026, 1, 20).jie, '小寒'); + assert.equal(monthPillar(2026, 3, 1).jie, '立春'); + assert.equal(monthPillar(2026, 3, 1).name, '庚寅'); +}); + +test('时支以两小时为界,五鼠遁起时干', () => { + assert.equal(hourPillar(0, 23).branch, 0); + assert.equal(hourPillar(0, 0).branch, 0); + assert.equal(hourPillar(0, 1).branch, 1); + assert.equal(hourPillar(0, 13).branch, 7); + // 甲日子时起甲子 + assert.equal(hourPillar(0, 0).name, '甲子'); + // 乙日子时起丙子 + assert.equal(hourPillar(1, 0).name, '丙子'); + // 戊日子时起壬子 + assert.equal(hourPillar(4, 0).name, '壬子'); + // 丙日子时起戊子;亥时(21:00-23:00)为己亥 + assert.equal(hourPillar(2, 22).name, '己亥'); + assert.equal(hourPillar(2, 22).branch, 11); + // 同一地支,按小时求与按地支求必须一致 + for (let hour = 0; hour < 24; hour += 1) { + assert.equal(hourPillar(2, hour).name, hourPillarByBranch(2, hourPillar(2, hour).branch).name); + } +}); + +test('十二时辰的干支与时辰一一对应,不错位', () => { + const snapshot = almanac(new Date(2026, 8, 29, 14, 30)); + // 丙午日的十二时:戊子 己丑 庚寅 辛卯 壬辰 癸巳 甲午 乙未 丙申 丁酉 戊戌 己亥 + assert.deepEqual( + snapshot.hours.map((item) => item.pillar), + ['戊子', '己丑', '庚寅', '辛卯', '壬辰', '癸巳', '甲午', '乙未', '丙申', '丁酉', '戊戌', '己亥'], + ); + // 时支必须与时辰名一致 + for (const item of snapshot.hours) { + assert.equal(item.pillarBranch, item.branch); + } + // 十二个干支互不相同 + assert.equal(new Set(snapshot.hours.map((item) => item.pillar)).size, 12); +}); + +test('黄黑道十二神按日支三合局起青龙', () => { + // 寅午戌日,青龙起寅时 + assert.equal(hourOffice(6, 2).name, '青龙'); + assert.equal(hourOffice(6, 2).auspicious, true); + // 申子辰日,青龙起子时 + assert.equal(hourOffice(0, 0).name, '青龙'); + // 巳酉丑日,青龙起巳时 + assert.equal(hourOffice(5, 5).name, '青龙'); + // 亥卯未日,青龙起亥时 + assert.equal(hourOffice(11, 11).name, '青龙'); + // 一日十二辰顺行,青龙之后依次为明堂、天刑、朱雀、金匮、天德 + assert.equal(hourOffice(6, 3).name, '明堂'); + assert.equal(hourOffice(6, 4).name, '天刑'); + assert.equal(hourOffice(6, 5).name, '朱雀'); + // 寅午戌日的六个吉时:寅、卯、午、未、酉、子 + const auspicious = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] + .filter((branch) => hourOffice(6, branch).auspicious) + .map((branch) => '子丑寅卯辰巳午未申酉戌亥'[branch]); + assert.deepEqual(auspicious, ['子', '寅', '卯', '午', '未', '酉']); +}); + +test('建除以日支同月支为建,顺行一位', () => { + assert.equal(jianchu(6, 6).name, '建'); + assert.equal(jianchu(7, 6).name, '除'); + assert.equal(jianchu(8, 6).name, '满'); + assert.equal(jianchu(5, 6).name, '闭'); + // 丙午日(6) 遇酉月(9):日支退三位,为收 + assert.equal(jianchu(6, 9).name, '收'); + // 退到初位绕回闭 + assert.equal(jianchu(5, 6).name, '闭'); +}); + +test('数九自冬至起算,九九八十一天', () => { + assert.equal(shujiu(2025, 12, 22).label, '一九'); + assert.equal(shujiu(2025, 12, 30).label, '一九'); + assert.equal(shujiu(2025, 12, 31).label, '二九'); + // 冬至后第 72 天为九九首日,第 81 天后出九 + assert.equal(shujiu(2026, 3, 3).label, '八九'); + assert.equal(shujiu(2026, 3, 4).label, '九九'); + assert.equal(shujiu(2026, 3, 12).stage, '出九'); + assert.equal(shujiu(2026, 3, 13), null); + assert.equal(shujiu(2026, 9, 29), null); +}); + +test('铜钱摇卦:六为老阴、九为老阳,变卦取反', () => { + // 掷钱结果自初爻起:[初爻老阳, 其余少阳] → 乾为天动初爻 + const reading = buildReading(castByCoins([9, 7, 7, 7, 7, 7])); + assert.equal(reading.hexagram.name, '乾为天'); + assert.equal(reading.movingLines.length, 1); + assert.equal(reading.movingLines[0].position, 1); + assert.equal(reading.movingLines[0].kind, '老阳'); + // 初爻由阳变阴,本卦 111111 变 011111,上乾下巽 + assert.equal(reading.changed.name, '天风姤'); + + // 少阳少阴交替,六爻皆静:本卦 101010 上坎下离,无变卦 + const still = buildReading(castByCoins([7, 8, 7, 8, 7, 8])); + assert.equal(still.movingLines.length, 0); + assert.equal(still.changed, null); + assert.equal(still.hexagram.name, '水火既济'); + assert.deepEqual(still.lines.map((line) => line.kind), ['少阳', '少阴', '少阳', '少阴', '少阳', '少阴']); + + // 上爻掷出老阴(6):本卦 111110,下乾上兑为泽天夬,上爻由阴变阳得乾 + const top = buildReading(castByCoins([7, 7, 7, 7, 7, 6])); + assert.equal(top.hexagram.name, '泽天夬'); + assert.equal(top.movingLines[0].kind, '老阴'); + assert.equal(top.changed.name, '乾为天'); +}); + +test('摇卦拒绝非法掷钱结果', () => { + assert.throws(() => castByCoins([7, 7, 7, 7, 7]), /六次/); + assert.throws(() => castByCoins([7, 7, 7, 7, 7, 5]), /6 到 9/); + assert.throws(() => castByCoins([7, 7, 7, 7, 7, 10]), /6 到 9/); + + // NaN、undefined 与非数都放得过去:它们跟任何数比较都是 false, + // `sum < 6 || sum > 9` 一条都拦不住,于是六爻全判成阴,安静地组出一个坤卦。 + // 页面那条路由是把请求里的 sums 用 Number() 转过来的,转不动的就是 NaN。 + for (const bad of [[NaN, NaN, NaN, NaN, NaN, NaN], [undefined, 7, 7, 7, 7, 7], ['7', 7, 7, 7, 7, 7]]) { + assert.throws(() => castByCoins(bad), /6 到 9/, `非法掷钱结果 ${JSON.stringify(bad)} 竟被放过去了`); + } + assert.throws(() => castByCoins({ sum: 7, coins: [true, true, true] }), /6 到 9/, '单次掷钱的对象被当成了六次结果'); + // 长度对得上的类数组也一样:只要每项不是整数就该拦下,哪怕它连 .some 都有。 + assert.throws( + () => castByCoins({ length: 6, some: () => false, map: () => [], reduce: () => [] }), + /6 到 9/, + '一个 length 恰好是 6 的类数组被放过去了', + ); +}); + +test('每日一卦同日同结果', () => { + const first = buildReading(castDaily(new Date(2026, 8, 29, 0, 5))); + const second = buildReading(castDaily(new Date(2026, 8, 29, 23, 55))); + assert.equal(first.hexagram.name, second.hexagram.name); + assert.equal(first.hexagram.key, second.hexagram.key); + assert.deepEqual(first.movingLines.map((line) => line.position), second.movingLines.map((line) => line.position)); +}); + +test('体用生克定吉凶,主客相隔三位', () => { + // 动爻在四爻以上,体卦为上卦 + const reading = buildReading(castByNumbers(1, 8), { now: new Date(2026, 8, 29) }); + assert.ok(reading.structure.shi.position >= 1 && reading.structure.shi.position <= 6); + const gap = Math.abs(reading.structure.shi.position - reading.structure.ying.position); + assert.equal(gap, 3); + // 「世应」两个字归京房:世爻由宫与世次定,梅花这层只说主客。 + assert.match(reading.structure.shi.role, /动爻/); + assert.match(reading.structure.ying.role, /配爻/); + assert.ok(!/世爻|应爻/.test(reading.structure.shi.role + reading.structure.ying.role)); + // 徽章与结论行用的是同一份判语,两边对「吉凶有哪几档」的说法也得是同一套。 + // 从前徽章只可能出现生克那三档、结论行却有五档,同一个字段两种词汇表。 + assert.ok(['大吉', '吉', '平', '凶', '大凶'].includes(reading.verdict.label), `徽章冒出不在册的吉凶「${reading.verdict.label}」`); + assert.match(reading.verdict.summary, /^(大吉|吉|平|凶|大凶):/); + assert.ok( + reading.verdict.summary.startsWith(`${reading.verdict.label}:`), + `徽章说「${reading.verdict.label}」而结论行说「${reading.verdict.summary}」,两处吉凶不是一回事`, + ); +}); + +test('旺相休囚死以春木令为基准', () => { + // 摇出乾卦,体用皆金;三月为木令,金当囚 + const spring = buildReading(castByCoins([7, 7, 7, 7, 7, 7]), { now: new Date(2026, 2, 20) }); + assert.equal(spring.structure.monthElement, '木'); + assert.equal(spring.structure.body.element, '金'); + assert.equal(spring.structure.bodyVitality, '囚'); + // 九月为金令,金当旺 + const autumn = buildReading(castByCoins([7, 7, 7, 7, 7, 7]), { now: new Date(2026, 8, 29) }); + assert.equal(autumn.structure.monthElement, '金'); + assert.equal(autumn.structure.bodyVitality, '旺'); +}); + +test('解读给出互错综三卦与完整断语', () => { + const reading = buildReading(castByCoins([7, 8, 9, 6, 7, 8])); + assert.ok(reading.mutual.name); + assert.ok(reading.opposite.name); + assert.ok(reading.inverted.name); + assert.ok(reading.advice.suitable.length > 0); + assert.ok(reading.advice.avoid.length > 0); + assert.ok(reading.details.length > 0); + for (const title of ['卦象总断', '体用关系', '旺衰应期', '互卦 · 过程', '变卦 · 结果', '错卦 · 旁支', '综卦 · 反求', '六亲世应', '主客', '取象']) { + assert.ok(reading.insights.some((item) => item.title === title), `缺少断语:${title}`); + } +}); + +test('断语恒含应期,不再有用神段', () => { + const reading = buildReading(castByCoins([7, 8, 9, 6, 7, 8])); + const titles = reading.insights.map((item) => item.title); + assert.equal(titles.includes('用神 · 所问之事'), false); + assert.ok(titles.includes('应期')); + assert.ok(reading.timing.length > 0); +}); + +test('应期取用卦五行,随用卦而变', () => { + // 乾金为用卦时,旺在申酉 + const metal = buildReading(castByNumbers(1, 1)); + assert.equal(metal.structure.use.name, '乾'); + assert.match(metal.timing, /申酉/); + const titles = metal.insights.map((item) => item.title); + assert.ok(titles.includes('应期')); +}); + +test('应期取用神五行的旺相之地', () => { + // 离火:旺巳午,相辰戌丑未 + assert.equal(responseTiming('火').wang, '巳午'); + assert.equal(responseTiming('火').xiang, '辰戌丑未'); + // 乾金:旺申酉,相亥子丑 + assert.equal(responseTiming('金').wang, '申酉'); + assert.equal(responseTiming('金').xiang, '亥子丑'); + // 震木:旺寅卯,相巳午 + assert.equal(responseTiming('木').wang, '寅卯'); + assert.equal(responseTiming('水').xiang, '寅卯'); +}); + +test('吉时仍带「省力」之戒,凶时带「守」之戒', () => { + const good = buildReading(castByCoins([9, 7, 7, 7, 7, 7]), { now: new Date(2026, 8, 29) }); + assert.equal(good.verdict.score >= 1, true); + assert.match(good.advice.caution, /力气要省|方向可进/); + const bad = buildReading(castByCoins([7, 7, 7, 7, 7, 6]), { now: new Date(2026, 8, 29) }); + assert.ok(bad.advice.caution.length >= 0); +}); + +test('爻位之象随动爻位置变化', () => { + const first = buildReading(castByCoins([9, 7, 7, 7, 7, 7])); + const top = buildReading(castByCoins([7, 7, 7, 7, 7, 6])); + const firstTitle = first.insights.find((item) => item.title.startsWith('爻位')); + const topTitle = top.insights.find((item) => item.title.startsWith('爻位')); + assert.match(firstTitle.title, /初爻/); + assert.match(topTitle.title, /上爻/); + assert.notEqual(firstTitle.text, topTitle.text); +}); + +test('方所取后天八卦方位', () => { + const reading = buildReading(castByCoins([7, 7, 7, 7, 7, 7])); + assert.equal(reading.structure.body.direction, '西北'); + assert.equal(reading.structure.use.direction, '西北'); + assert.match(reading.insights.find((item) => item.title === '方所').text, /西北/); +}); + +test('时间起卦随时辰变,且预告下一时辰', () => { + const morning = buildReading(castByTime(new Date(2026, 8, 29, 7, 30))); + const sameWatch = buildReading(castByTime(new Date(2026, 8, 29, 8, 30))); + const nextWatch = buildReading(castByTime(new Date(2026, 8, 29, 10, 0))); + + // 辰时 07:00-08:59 内结果相同 + assert.equal(morning.hexagram.name, sameWatch.hexagram.name); + assert.equal(morning.movingLines.length, sameWatch.movingLines.length); + // 跨时辰必变 + assert.notEqual(morning.hexagram.name, nextWatch.hexagram.name); + + assert.equal(morning.cadence.basis, '时辰'); + assert.match(morning.cadence.current, /辰时/); + assert.match(morning.cadence.next, /巳时得/); + assert.match(morning.cadence.text, /两小时一换/); + // 预告的下一时辰卦,必须与真的在下一时辰起卦一致 + const predicted = morning.cadence.next.match(/巳时得(.+?),动(.+)$/); + assert.ok(predicted, `无法解析预告:${morning.cadence.next}`); + assert.equal(predicted[1], nextWatch.hexagram.name); + assert.equal(predicted[2], nextWatch.movingLines[0].label); +}); + +test('每日一卦一天一换,且预告明日之卦', () => { + const today = buildReading(castDaily(new Date(2026, 8, 29, 1, 0))); + const sameDay = buildReading(castDaily(new Date(2026, 8, 29, 23, 0))); + const tomorrow = buildReading(castDaily(new Date(2026, 8, 30, 0, 0))); + + assert.equal(today.hexagram.name, sameDay.hexagram.name); + assert.notEqual(today.hexagram.name, tomorrow.hexagram.name); + assert.equal(today.cadence.basis, '日期'); + assert.match(today.cadence.next, /明日(9\/30)得/); + assert.match(today.cadence.text, /一日一换/); + assert.match(today.cadence.next, new RegExp(tomorrow.hexagram.name)); +}); + +test('数字起卦由所取之数决定,摇卦每卦皆不同', () => { + const a = buildReading(castByNumbers(3, 5)); + const b = buildReading(castByNumbers(3, 5)); + const c = buildReading(castByNumbers(7, 11)); + assert.equal(a.hexagram.name, b.hexagram.name); + assert.notEqual(a.hexagram.name, c.hexagram.name); + assert.match(a.cadence.text, /数字相同自然卦相同/); + assert.equal(buildReading(castByCoins([7, 7, 7, 7, 7, 7])).cadence.basis, '铜钱'); +}); + +test('摇卦重复投掷多数会得到不同的卦', () => { + const seen = new Set(); + for (let i = 0; i < 20; i += 1) { + const sums = Array.from({ length: 6 }, () => 6 + Math.floor(Math.random() * 4)); + seen.add(castByCoins(sums).hexagram.name); + } + assert.ok(seen.size > 1, '摇卦不应恒定'); +}); + +test('掷钱结果落在 6 到 9 之间', () => { + for (let i = 0; i < 60; i += 1) { + const toss = tossCoins(); + assert.equal(toss.coins.length, 3); + assert.ok(toss.sum >= 6 && toss.sum <= 9); + } +}); + +test('卦历写入 dataDir 后可回读', async () => { + const dir = await mkdtemp(join(tmpdir(), 'divination-store-')); + try { + const store = new ReadingStore(dir); + assert.deepEqual(await store.list(), []); + + const reading = buildReading(castByCoins([7, 7, 7, 7, 7, 7]), { question: '测试' }); + const saved = await store.save(reading, '批注内容'); + assert.equal(saved.id, reading.id); + assert.equal(saved.note, '批注内容'); + // 改批注那条路(store.update)连同路由一起没有,所以 updatedAt 永远是 undefined。 + // 把这个恒为 undefined 的字段挂回 summary,只会让读代码的人以为还有「改批注」这回事。 + assert.ok(!('updatedAt' in saved), 'summary 里不该有恒为 undefined 的 updatedAt'); + + const listed = await store.list(); + assert.equal(listed.length, 1); + assert.equal(listed[0].verdict.label, reading.verdict.label); + + const full = await store.get(reading.id); + assert.equal(full.hexagram.name, reading.hexagram.name); + assert.equal(full.insights.length, reading.insights.length, '落盘再读回,断语段数得跟起卦时一致'); + // 乾为天是六冲卦,「六冲」那一段必然在。 + assert.ok(full.insights.some((item) => item.title === '六冲'), '六冲卦的断语里该有「六冲」那一段'); + assert.equal(full.clash.chong, true, '六冲卦这个定性也要跟着落盘走'); + // 这里原来还写死了一个段数(17)。写死不得:卦上六爻皆动,「逢合」那一段是否出, + // 取决于日支那一支的合支落不落在子寅辰午申戌里——日支为子寅辰午申戌之外的奇数支就出, + // 为偶数支就不出,同一个卦同一副摇法,隔一天段数就变。跟着它改数字,哪天改漏了 + // 或者改错了,报出来的是「段数不对」,得回头去数是哪一段。段数一致这件事上面那句 + // 已经钉住了,这里只留必然在的那一段。 + const conditional = full.insights.filter((item) => item.title === '六冲' || item.title.startsWith('逢合')); + assert.ok(conditional.length >= 1 && conditional.length <= 2, + `条件段该在一段到两段之间,实到 ${conditional.length} 段:${conditional.map((i) => i.title).join('、')}`); + + assert.equal(await store.remove(reading.id), true); + assert.equal(await store.remove(reading.id), false); + assert.deepEqual(await store.list(), []); + + // 落盘文件是合法 JSON,不留临时文件 + const onDisk = JSON.parse(await readFile(join(dir, 'readings.json'), 'utf8')); + assert.equal(onDisk.length, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('同一秒内两次同样的起法,id 必须分开', async () => { + // 数字起卦只取决于那两个数,与时辰无关。于是「同一秒、同两个数」得到的是 + // 卦序与动爻分毫不差的一卦——id 若只拿时间戳加卦序动爻去哈希,两次必然相同。 + // 而客户端的起卦按钮在整个推演动画里一直可点(要停 CASTING_HOLD_MS 那么多), + // 双击就真的会发出两次请求。这条钉的是根因:id 不许撞。 + const now = new Date('2026-09-30T13:50:00.000Z'); + const first = buildReading(castByNumbers(17, 29), { question: '甲', now }); + const second = buildReading(castByNumbers(17, 29), { question: '乙', now }); + assert.equal(first.hexagram.name, second.hexagram.name, '同一秒同两数,起出来的卦本就该是同一卦'); + assert.notEqual(first.id, second.id, '同一秒内两次同样的起法,id 撞了:卦历里删一条会连带删另一条'); + + // 顺带钉住 id 的形状:路由用 /^\/api\/divination\/history\/([A-Za-z0-9-]{1,80})$/ 取 id, + // 掺进种子的那个计数不许改到输出格式上。消息要自己写:assert.match 不带消息时 + // 抛的是默认文案,按关键词判「钉没钉住」会一条都对不上。 + assert.match(first.id, /^[0-9]{14}-[a-z0-9]{1,6}$/u, 'id 的形状变了,路由取不到它'); + + // 撞 id 的真实后果:两条都存进卦历,删一条只该带走那一条。 + const dir = await mkdtemp(join(tmpdir(), 'divination-store-')); + try { + const store = new ReadingStore(dir); + await store.save(first, '甲的批注'); + await store.save(second, '乙的批注'); + assert.equal((await store.list()).length, 2, '两条都该在'); + + assert.equal(await store.remove(first.id), true); + const left = await store.list(); + assert.equal(left.length, 1, '删一条连带删了两条——id 又撞回去了'); + assert.equal(left[0].id, second.id, '留下的那条不是被点删除的那条'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('落盘改名被 Windows 占住时退避重试,其余错误直接抛', async () => { + // Windows 上目标文件正被别的进程打开、且未共享删除权限时(杀毒扫描、 + // Windows Search 索引都可能占着),改名整个失败并报 EPERM 或 EACCES—— + // 不是部分写入,是压根没换。退避重试几次,对方松手就成了。 + for (const code of ['EPERM', 'EACCES', 'EBUSY']) { + assert.equal(shouldRetryRename({ code }, 0), true, `${code} 该重试`); + } + // 其余错误码重试也没用:磁盘满、只读盘、目录不存在,重试四次都是同一个结果, + // 只会把这一次保存吊住几百毫秒。 + for (const code of ['ENOSPC', 'EROFS', 'ENOENT', 'EXDEV', 'EISDIR']) { + assert.equal(shouldRetryRename({ code }, 0), false, `${code} 不该重试`); + } + // 次数必须有上限,不然一个真被占住的目标能把请求一直吊着。 + assert.equal(shouldRetryRename({ code: 'EPERM' }, 3), true, '最后一次之前仍该重试'); + assert.equal(shouldRetryRename({ code: 'EPERM' }, 4), false, '重试次数没有上限'); + // 错误对象里取不到 code 时不许当成可重试——那多半是别处的错。 + // 这两条消息要自己写:不带消息时抛的是默认文案,按关键词判「钉没钉住」一条都对不上。 + assert.equal(shouldRetryRename(new Error('boom'), 0), false, '取不到 code 的错误不该重试'); + assert.equal(shouldRetryRename(undefined, 0), false, '连错误对象都没有,不该重试'); + + // 光把上面这个纯函数测绿是不够的:它是个判断,调用它的是 writeAll。 + // 有人把 writeAll 里的重试那一段删掉(或者改成无条件重试),纯函数照样全绿。 + // 所以再钉一次调用点。 + const source = await readFile(new URL('../miniapp/node/store.mjs', import.meta.url), 'utf8'); + const writeAll = /async writeAll\(entries\) \{([\s\S]*?)\n \}/.exec(source); + assert.ok(writeAll, '找不到 writeAll'); + assert.ok( + /shouldRetryRename\(error, attempt\)/.test(writeAll[1]), + 'writeAll 没有拿 shouldRetryRename 决定要不要重试——退避重试形同虚设', + ); + assert.ok( + /if \(!shouldRetryRename\(error, attempt\)\) throw error;/.test(writeAll[1]), + 'writeAll 遇到不该重试的错误没有直接抛出去', + ); + assert.ok( + /catch \(error\)[\s\S]{0,200}?await delay\(/.test(writeAll[1]), + 'writeAll 重试之前没有退避,等于连着猛敲', + ); +}); + +test('卦历落盘文件坏了也还能打开,坏的那份挪开留着', async () => { + const dir = await mkdtemp(join(tmpdir(), 'divination-store-')); + try { + // 写到一半被打断、被别的程序改过、被同步软件截断,都会落成这个样子。 + await writeFile(join(dir, 'readings.json'), '{ 这不是 JSON', 'utf8'); + const store = new ReadingStore(dir); + + // 从前是直接抛,于是 list/save/remove 全线 500:卦历打不开,用户连自救的入口都没有。 + // 兜住拒绝再断言:坏文件那条路一旦退回直接抛,错误会在断言之前就把用例掀翻, + // 报出来的是一句 JSON 解析错,看不出是这条契约被破了。 + const listed = await store.list().catch((error) => ({ threw: error?.message ?? String(error) })); + assert.deepEqual(listed, [], `坏文件不该把整个卦历顶死(实际:${JSON.stringify(listed)})`); + + // 坏的那份挪到一边另存而不是删掉——里面可能有用户手写的批注,捞得回来。 + const aside = (await readdir(dir)).filter((name) => name.includes('corrupt')); + assert.equal(aside.length, 1, `坏文件该被挪开一份,实得 ${aside.join('、') || '一份都没有'}`); + + // 挪开之后能接着存,且原文件重新立起来。 + const reading = buildReading(castByCoins([7, 7, 7, 7, 7, 7]), { question: '坏过之后' }); + await store.save(reading, '批注'); + const afterSave = await store.list(); + assert.equal(afterSave.length, 1, '挪开之后应当还能存进去'); + assert.equal(afterSave[0].id, reading.id); + + // 坏的那份还在盘上,没有被后来的写入盖掉。 + assert.equal((await readdir(dir)).filter((name) => name.includes('corrupt')).length, 1); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('卦历按时间倒序并限量保存', async () => { + const dir = await mkdtemp(join(tmpdir(), 'divination-store-')); + try { + const store = new ReadingStore(dir); + for (let i = 0; i < 3; i += 1) { + const reading = buildReading(castByCoins([7, 7, 7, 7, 7, 7])); + await store.save({ ...reading, id: `id-${i}`, createdAt: `2026-01-0${i + 1}T00:00:00.000Z` }, ''); + } + const entries = await store.list(); + assert.deepEqual(entries.map((item) => item.id), ['id-2', 'id-1', 'id-0']); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('历法快照给出十二时辰与吉时', () => { + const snapshot = almanac(new Date(2026, 8, 29, 14, 30)); + assert.equal(snapshot.date, '2026-09-29'); + assert.equal(snapshot.hours.length, 12); + assert.equal(snapshot.hours.filter((item) => item.current).length, 1); + assert.equal(snapshot.hours.find((item) => item.current).name, '未时'); + assert.equal(snapshot.luckyHours.length, 6); +}); + +test('五行类事:问什么落到哪个事类与类神', () => { + assert.equal(detectTopic('下个月这份offer该不该接?')?.key, 'career'); + assert.equal(detectTopic('这笔投资还能不能赚钱')?.key, 'wealth'); + assert.equal(detectTopic('他会不会主动找我')?.key, 'love'); + assert.equal(detectTopic('明年春天结婚日子好不好')?.key, 'marriage'); + assert.equal(detectTopic('父亲的手术要不要等')?.key, 'health'); + assert.equal(detectTopic('下周的考试能过吗')?.key, 'study'); + assert.equal(detectTopic('这套房该不该买')?.key, 'property'); + assert.equal(detectTopic('这场官司我能不能赢')?.key, 'dispute'); + assert.equal(detectTopic('丢的那串钥匙还能找回吗')?.key, 'journey'); +}); + +test('类神口径:财运金、事业火、感情水、婚恋木、疾病土', () => { + const byKey = new Map(TOPIC_CLASSES.map((item) => [item.key, item])); + assert.equal(byKey.get('wealth').element, '金'); + assert.equal(byKey.get('career').element, '火'); + assert.equal(byKey.get('love').element, '水'); + assert.equal(byKey.get('marriage').element, '木'); + assert.equal(byKey.get('health').element, '土'); +}); + +test('认不出事类时不强套,宁可退回用卦', () => { + assert.equal(detectTopic('嗯'), null); + assert.equal(detectTopic('今天天气如何'), null); + assert.equal(detectTopic(''), null); + assert.equal(detectTopic(' '), null); +}); + +test('长词优先:找工作算事业,不算寻物', () => { + assert.equal(detectTopic('要不要换个工作')?.key, 'career'); + assert.equal(detectTopic('找不到对象怎么办')?.key, 'marriage'); +}); + +test('类神与体卦四种生克各有一句断语', () => { + const god = (godElement, bodyElement) => godRelation(godElement, bodyElement, generates, overcomes); + assert.equal(god('金', '木').key, 'god-overcomes-body'); // 金克木 + assert.equal(god('水', '木').key, 'god-generates-body'); // 水生木 + assert.equal(god('火', '木').key, 'body-generates-god'); // 木生火,耗己 + assert.equal(god('木', '金').key, 'body-overcomes-god'); // 金克木,我制事 + assert.equal(god('木', '木').key, 'god-same-as-body'); // 同气 + for (const element of ['木', '火', '土', '金', '水']) { + assert.ok(god(element, '木').text.length > 0); + } +}); + +test('问事改变应期与取象,但不改吉凶', () => { + const cast = castByNumbers(7, 9); + const plain = buildReading(cast, { now: new Date(2026, 8, 29, 23, 0) }); + const asked = buildReading(cast, { question: '这套房该不该买', now: new Date(2026, 8, 29, 23, 0) }); + + // 类神是土,旺在辰戌丑未;用卦是艮土,恰好同气,改用问财/问房要能看出差别 + assert.equal(plain.topic, null); + assert.equal(asked.topic.label, '房产车契'); + assert.equal(asked.topic.element, '土'); + assert.match(asked.timing, /房产车契类神属土/); + assert.ok(asked.insights.some((item) => item.title === '所问之事')); + + // 吉凶只由体用生克与月令决定,与问什么无关 + assert.equal(asked.verdict.key, plain.verdict.key); + assert.equal(asked.verdict.label, plain.verdict.label); + assert.equal(asked.structure.body.element, plain.structure.body.element); +}); + +test('认出事类时应期改看类神,不再是用卦', () => { + // 用 castByNumbers(1, 1):用卦乾金,本应看申酉;改问健康,类神取土,转看辰戌丑未 + const cast = castByNumbers(1, 1); + const plain = buildReading(cast); + assert.match(plain.timing, /用卦乾属金/); + assert.match(plain.timing, /申酉/); + + const asked = buildReading(cast, { question: '父亲的手术要不要等' }); + assert.match(asked.timing, /疾病类神属土/); + assert.match(asked.timing, /辰戌丑未/); + assert.equal(asked.timing.includes('用卦乾'), false); +}); + +/* ---------- 起卦中八卦环 ---------- */ +// 环上那八纯是手排的,跟引擎的 TRIGRAMS 各写一份。排错一位就是给人看错卦象, +// 所以爻序和方位都拿引擎当权威逐个对,不靠肉眼。 + +test('八卦环八纯的爻象与引擎 TRIGRAMS 一致', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const { TRIGRAMS } = await import('../miniapp/node/hexagrams.mjs'); + const source = Object.values(TRIGRAMS); + const table = /const BAGUA = \[([\s\S]*?)\n \];/.exec(client); + assert.ok(table, '客户端里找不到 BAGUA 八纯表'); + + const ring = [...table[1].matchAll(/name: '(.)', at: (\d+), lines: \[([^\]]+)\]/g)].map((m) => ({ + name: m[1], + at: Number(m[2]), + // 数组与引擎同约定:自下而上,[0] 是初爻 + key: m[3].split(',').map((s) => s.trim()).join(''), + })); + + assert.equal(ring.length, 8, '八卦环应当正好八纯'); + for (const item of ring) { + const ref = source.find((t) => t.name === item.name); + assert.ok(ref, `引擎里没有「${item.name}」`); + assert.equal(item.key, ref.lines, `「${item.name}」爻象与引擎不符`); + } +}); + +test('八卦环按后天八卦排位,角度对应引擎的 direction', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const { TRIGRAMS } = await import('../miniapp/node/hexagrams.mjs'); + const source = Object.values(TRIGRAMS); + const table = /const BAGUA = \[([\s\S]*?)\n \];/.exec(client)[1]; + + // SVG 里 rotate(0) 指向正上,顺时针排位 + const AT = { 0: '正北', 45: '东北', 90: '正东', 135: '东南', 180: '正南', 225: '西南', 270: '正西', 315: '西北' }; + const ring = [...table.matchAll(/name: '(.)', at: (\d+)/g)].map((m) => ({ name: m[1], at: Number(m[2]) })); + + for (const item of ring) { + const ref = source.find((t) => t.name === item.name); + assert.ok(AT[item.at], `${item.at}° 不是八卦位`); + assert.equal(ref.direction, AT[item.at], `「${item.name}」排在 ${item.at}°,应为 ${AT[item.at]}`); + } +}); + +test('爻线在八卦环里自下而上落笔,初爻在最下', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + // trigramBars 用 y = 9 - i * 9,落笔 y 必须随 i 递减,i=0(初爻)拿最大 y 即最下 + const formula = /const y = (\d+) - i \* (\d+);/.exec(client); + assert.ok(formula, '找不到 trigramBars 的落笔公式'); + const base = Number(formula[1]); + const step = Number(formula[2]); + assert.ok(base > 0 && step > 0, '初爻应落在 y 正方向(下方)'); + assert.equal(base - step * 2, -base, '三爻应关于中线对称'); +}); + +/* ---------- 动效的定位契约 ---------- */ +// 这两处都是「CSS animation 的 transform 会覆盖定位 transform」引出来的坑: +// 元素靠 translateX(-50%) 居中,而 keyframes 里的 scale() 会把它整个顶掉。 + +test('台面光晕的每一帧都保住水平居中', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const frames = /@keyframes tossGlow \{([\s\S]*?)\n \}/.exec(client); + assert.ok(frames, '找不到 tossGlow'); + const transforms = [...frames[1].matchAll(/transform:\s*([^;]+);/g)].map((m) => m[1]); + assert.ok(transforms.length >= 2, 'tossGlow 应当有多帧'); + for (const t of transforms) { + assert.match(t, /translateX\(-50%\)/, `tossGlow 某帧是 "${t}",scale() 会顶掉居中偏移`); + } +}); + +test('投影只留一种居中方式', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const block = /\.coin-shadow \{([\s\S]*?)\n \}/.exec(client); + assert.ok(block, '找不到 .coin-shadow'); + const margin = /margin-left:\s*-\d+px/.test(block[1]); + const transform = /transform:\s*translateX\(-50%\)/.test(block[1]); + assert.equal( + margin && transform, + false, + 'margin-left 负值和 translateX(-50%) 同时存在会叠加居中,投影会偏出铜钱', + ); + assert.equal(transform, true, '投影应当靠 translateX(-50%) 居中,各帧也要一致'); +}); + + +/* ---------- 起卦推演日志 ---------- */ +// 用户连起三卦都看到同一段话,那不叫在算。所以日志的每一步都取自引擎 +// reading.details——本次的年月日、时辰、所报两数、六次掷钱都在里面。 +// 下面把客户端的拼行与调速函数摘出来求值,用四法真实起卦跑一遍。 + +function loadCasting(client) { + const cut = (start, end) => { + const i = client.indexOf(start); + assert.ok(i >= 0, `客户端里找不到 ${start}`); + const j = client.indexOf(end, i); + assert.ok(j >= 0, `客户端里找不到 ${start} 的结尾`); + return client.slice(i, j + end.length); + }; + const code = [ + cut('const CASTING_OPENERS = [', '];'), + cut('const CASTING_CLOSERS = [', '];'), + ...['CASTING_LINE_MS', 'CASTING_BUDGET_MS', 'CASTING_BASE_CHAR_MS', 'CASTING_CHAR_MIN', 'CASTING_CHAR_MAX', 'CASTING_MAX_LINES', 'CASTING_HOLD_MS'] + .map((name) => cut(`const ${name} = `, ';')), + cut('const pick = ', ';'), + cut('function castingAftermath(reading) {', '\n }'), + cut('function castingLines(reading) {', '\n }'), + cut('function castingSpeed(lines) {', '\n }'), + ].join('\n'); + return new Function(`${code}\nreturn { castingLines, castingSpeed, castingAftermath, CASTING_MAX_LINES, CASTING_BUDGET_MS, CASTING_HOLD_MS, CASTING_OPENERS, CASTING_CLOSERS };`)(); +} + +const SAMPLES = () => [ + ['每日一卦', castDaily(new Date('2026-09-30T01:20:00+08:00'))], + ['时间起卦', castByTime(new Date('2026-09-30T01:20:00+08:00'))], + ['数字起卦', castByNumbers(37, 24)], + ['铜钱摇卦', castByCoins([7, 8, 7, 8, 9, 6])], +]; + +test('推演的每一步都来自这次的真实取数', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const { castingLines } = loadCasting(client); + + for (const [name, cast] of SAMPLES()) { + const reading = buildReading(cast); + const lines = castingLines(reading); + const steps = reading.details.map((d) => d.value); + const shown = lines.filter((line) => steps.includes(line)); + assert.ok(shown.length > 0, `${name} 的推演里应当有引擎给的取数步骤`); + assert.ok(lines.length >= 3, `${name} 推演只有 ${lines.length} 行,太单薄`); + } +}); + +test('换个时辰、换组数重起,日志就跟着换', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const { castingLines } = loadCasting(client); + + const before = castingLines(buildReading(castByTime(new Date('2026-09-30T01:20:00+08:00')))); + const later = castingLines(buildReading(castByTime(new Date('2026-09-30T05:20:00+08:00')))); + const other = castingLines(buildReading(castByNumbers(11, 7))); + assert.notDeepEqual(before, later, '时辰不同,日志不该逐字相同'); + assert.notDeepEqual(before, other, '报的两数不同,日志不该逐字相同'); +}); + +test('首尾措辞各有多个候选,同卦重起也不至于一模一样', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const { CASTING_OPENERS, CASTING_CLOSERS } = loadCasting(client); + assert.ok(CASTING_OPENERS.length >= 3, `开场白只有 ${CASTING_OPENERS.length} 个候选`); + assert.ok(CASTING_CLOSERS.length >= 3, `收尾只有 ${CASTING_CLOSERS.length} 个候选`); +}); + +test('四法起卦,日志都在整段停留里打完', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const { castingLines, castingSpeed, CASTING_HOLD_MS, CASTING_BUDGET_MS } = loadCasting(client); + const lineMs = Number(/const CASTING_LINE_MS = (\d+);/.exec(client)[1]); + + // 停留时长只有一个来源,就是 CASTING_HOLD_MS。调用点若另写一个数, + // 推演一改长就切在末行上,而末行正是压轴那句。 + assert.ok( + /await wait\(reducedMotion\(\) \? 0 : CASTING_HOLD_MS\);/.test(client), + '停留时长没有取 CASTING_HOLD_MS,调用点跟常量脱钩了', + ); + + for (const [name, cast] of SAMPLES()) { + const lines = castingLines(buildReading(cast)); + const chars = lines.reduce((sum, text) => sum + text.length, 0); + const total = chars * castingSpeed(lines) + (lines.length - 1) * lineMs; + // 预算得真是上界。收尾那句的字数若不预留,实际时长会顶穿预算—— + // 那句在停留余量之内时看不出毛病,可一旦调小余量就先砍掉它。 + assert.ok(total <= CASTING_BUDGET_MS, `${name} 要 ${total}ms,顶穿了推演预算 ${CASTING_BUDGET_MS}ms`); + assert.ok(total <= CASTING_HOLD_MS, `${name} 要 ${total}ms,超过停留 ${CASTING_HOLD_MS}ms,末行会被砍`); + } +}); + +test('推演不止取数那几句,成卦之后的话也都播出来', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const { castingLines, castingAftermath } = loadCasting(client); + + for (const [name, cast] of SAMPLES()) { + const reading = buildReading(cast); + const aftermath = castingAftermath(reading); + // 四类都在:成卦、变卦、宫世应、体用。少一类就又回到「取完数就没话说了」。 + assert.equal(aftermath.length, 4, `${name} 成卦之后只凑出 ${aftermath.length} 句:${aftermath.join(' / ')}`); + + const lines = castingLines(reading); + // 造了不等于播了。更要紧的三句一条都不许被挤掉——它们排在体用句之前, + // 预算再紧也先丢体用句。哪句被挤掉本身就是要紧程度排错了。 + for (const text of aftermath.slice(0, 3)) { + assert.ok(lines.includes(text), `${name}「${text}」被挤出了推演,顺序没按要紧程度排`); + } + // 收尾那句不受行数上限管,必播。 + assert.ok(lines.length >= 5, `${name} 推演只有 ${lines.length} 行,收尾句没留住`); + } +}); + +test('成卦那几句各钉各的字段,改一个不会牵连别句', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const { castingAftermath } = loadCasting(client); + const base = buildReading(castByNumbers(17, 29)); + const before = castingAftermath(base); + assert.equal(before.length, 4, `这一卦该有四句,实际 ${before.length} 句`); + + // 每一个字段单开一条。合在一起改的话,名字一改整句就变了, + // 元素、爻位这些跟着改却没被验到——「体用句不报五行」那种变异就钻过去了。 + const cases = [ + ['成卦句的本卦名', 0, (r) => { r.hexagram.name = '本卦名改'; }], + ['成卦句的上卦', 0, (r) => { r.hexagram.upper.name = '上卦改'; }], + ['成卦句的下卦', 0, (r) => { r.hexagram.lower.name = '下卦改'; }], + ['变卦句的变卦名', 1, (r) => { r.changed.name = '变卦名改'; }], + ['变卦句的动爻', 1, (r) => { r.movingLines = [{ ...r.movingLines[0], label: '动爻改' }]; }], + ['宫世应句的宫', 2, (r) => { r.jingfang.palaceName = '宫名改'; }], + ['宫世应句的世次', 2, (r) => { r.jingfang.stage = '五世'; }], + ['宫世应句的世爻', 2, (r) => { r.lines[r.jingfang.shi - 1].label = '世爻改'; }], + ['宫世应句的应爻', 2, (r) => { r.lines[r.jingfang.ying - 1].label = '应爻改'; }], + ['体用句的体卦名', 3, (r) => { r.structure.body.name = '体名改'; }], + ['体用句的体卦五行', 3, (r) => { r.structure.body.element = '火'; }], + ['体用句的用卦名', 3, (r) => { r.structure.use.name = '用名改'; }], + ['体用句的用卦五行', 3, (r) => { r.structure.use.element = '土'; }], + ]; + + for (const [what, index, patch] of cases) { + // 必须深拷贝:buildReading 里的卦名、六亲都是共享常量表里的对象, + // 直接改会连底库一起改掉,后面几条用例与别处用例全被带歪。 + const reading = structuredClone(buildReading(castByNumbers(17, 29))); + patch(reading); + const after = castingAftermath(reading); + assert.notEqual(after[index], before[index], `${what}没跟着 reading 变,说明这句是写死的`); + } +}); + +test('改一句的字段不牵连别句——四句各读各的', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const { castingAftermath } = loadCasting(client); + const before = castingAftermath(buildReading(castByNumbers(17, 29))); + + // 只挑四句互不相干的字段。爻位是另一回事:应爻恰好又是动爻时, + // 变卦句与宫世应句读的就是同一份数据,两句一起变才对,不算串。 + const isolated = [ + ['本卦名', 0, (r) => { r.hexagram.name = '本卦名改'; }], + ['变卦名', 1, (r) => { r.changed.name = '变卦名改'; }], + ['宫名', 2, (r) => { r.jingfang.palaceName = '宫名改'; }], + ['体卦五行', 3, (r) => { r.structure.body.element = '火'; }], + ]; + + for (const [what, index, patch] of isolated) { + const reading = structuredClone(buildReading(castByNumbers(17, 29))); + patch(reading); + const after = castingAftermath(reading); + assert.notEqual(after[index], before[index], `${what}改了但对应那句没变,这条没验到`); + for (const other of [0, 1, 2, 3].filter((i) => i !== index)) { + assert.equal(after[other], before[other], `${what}改了,${before[other]}也跟着变了,两句串在一起了`); + } + } +}); + +test('推演日志定高留够播报的最大行数,收尾那句不会挤出去', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const { CASTING_MAX_LINES } = loadCasting(client); + + const block = /\.casting-log \{([\s\S]*?)\}/.exec(client); + assert.ok(block, '找不到 .casting-log 的样式'); + const minHeight = Number(/min-height: (\d+)px/.exec(block[1])?.[1]); + const fontSize = Number(/font-size: (\d+)px/.exec(block[1])?.[1]); + const lineHeight = Number(/line-height: ([\d.]+)/.exec(block[1])?.[1]); + assert.ok(minHeight && fontSize && lineHeight, `.casting-log 的 min-height / font-size / line-height 解析不出来`); + + // 每行 nowrap,定的是固定行高。收尾那句不受行数上限管,要按 MAX_LINES + 1 留。 + const need = (CASTING_MAX_LINES + 1) * fontSize * lineHeight; + assert.ok( + minHeight >= need, + `定高 ${minHeight}px 装不下 ${CASTING_MAX_LINES + 1} 行(需 ${need.toFixed(1)}px),最后一句会挤到卦盘上`, + ); +}); + +test('标题与分区合成一条钉住的顶栏', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + + const block = /\.topbar \{([\s\S]*?)\n \}/.exec(client); + assert.ok(block, '找不到 .topbar 的样式'); + const style = block[1]; + assert.ok(/position: sticky/.test(style), '顶栏没钉在顶部'); + assert.ok(/top: 0/.test(style), '顶栏没贴到视口顶边'); + assert.ok(/z-index: \d+/.test(style), '顶栏没排层,滑下去会被内容盖住'); + assert.ok(/backdrop-filter: blur/.test(style), '顶栏底色不透明,滑下去内容会硬生生撞上来'); + assert.ok(/border-bottom: 1px solid/.test(style), '顶栏下缘没有分隔线'); + // 底色要拉成整条,就得抵消 .app 的上内边距与左右内边距。 + // 抵消量写死成 -32px 的话,版心内边距一收放,底色就不贴视口边了; + // 现在它取 --app-pad 的相反数(下面那条断言钉住这一点),这里只管「必须是负的」。 + const margin = /margin: ([^;]+);/.exec(style); + assert.ok(margin, '找不到顶栏的外边距'); + const parts = margin[1].trim().split(/\s+(?![^(]*\))/); + const negative = (v) => /^-/.test(v) || /\*\s*-\d/.test(v); + assert.ok(parts.length >= 2 && negative(parts[0]) && negative(parts[1]), '顶栏没抵消 .app 的内边距,底色只在内容区那条窄带里'); + + const open = /
([\s\S]*?)<\/div>\s*
]/.test(open[1]), '顶栏里没有 header'); + assert.ok(/role="tablist"/.test(open[1]), '顶栏里没有功能分区'); +}); + +test('解读页左栏钉住,错开量取顶栏高度而不是另写一个数', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + + const at = /@media \(min-width: (\d+)px\) and \(min-height: (\d+)px\)/.exec(client); + assert.ok(at, '解读页左栏没限定视口宽高,视口太矮时钉住会够不着自己的底'); + + const sticky = /\.reading > :first-child \{([\s\S]*?)\}/.exec(client); + assert.ok(sticky, '解读页左栏没钉住'); + assert.ok(/position: sticky/.test(sticky[1]), '解读页左栏不是 sticky'); + assert.ok(/top: calc\(var\(--topbar-h\)/.test(sticky[1]), '左栏的错开量没跟着顶栏高度走,顶栏一改高度就会压住内容'); + + // 顶栏高度只能有一个来源,否则顶栏一改高,左栏的错开量就成了另一个数。 + // 只数定义,不数使用点(var(--topbar-h) 后面没有冒号); + // 负向看着写,免得加一个 --topbar-h-sp 就绕过去。 + const defined = client.match(/--topbar-h(?![\w-])\s*:/g) ?? []; + assert.equal(defined.length, 1, `--topbar-h 定义了 ${defined.length} 次`); + + // :root 里那个数只是首屏兜底。顶栏在窄宽度下会换行变高——实测过:内容区 + // 收到 700px 时标题与干支条分两行,顶栏从 132px 长到 195px 上下, + // 左栏仍按 132px 错开,卦名直接被压在顶栏底下。所以高度得实测写回。 + const sync = /const syncTopbarHeight = \(\) => \{[\s\S]{0,300}?\n\s+\};/.exec(client); + assert.ok(sync, '没有把顶栏实测高度写回 --topbar-h 的那段'); + assert.ok(/setProperty\('--topbar-h'/.test(sync[0]), '同步函数没有写回 --topbar-h'); + assert.ok(/topbarEl\.offsetHeight/.test(sync[0]), '写回的不是实测高度'); + assert.ok( + /new ResizeObserver\(syncTopbarHeight\)[\s\S]{0,120}?observe\(topbarEl\)/.test(client), + '没有用 ResizeObserver 盯住顶栏,宽度一变就又对不上了', + ); + assert.ok(/pagehide[\s\S]{0,120}?topbarObserver\.disconnect\(\)/.test(client), '观察者没有断开'); +}); + +/* ---------- 响应式:按展示区域自己的宽度排,不按视口宽度排 ---------- */ + +/** 取出 CSS 里某个 at-rule 的每一段内容,按花括号配对,不靠猜缩进。 */ +function atRuleBlocks(css, keyword) { + const out = []; + const re = new RegExp(`@${keyword}\\b`, 'g'); + let m; + while ((m = re.exec(css)) !== null) { + const open = css.indexOf('{', m.index); + if (open === -1) continue; + let depth = 0; + let i = open; + for (; i < css.length; i += 1) { + if (css[i] === '{') depth += 1; + else if (css[i] === '}') { + depth -= 1; + if (depth === 0) break; + } + } + out.push(css.slice(open + 1, i)); + } + return out; +} + +/** 读某条选择器上 repeat(n, ...) 的 n;不是 repeat 就返回 null。 */ +function repeatCount(css, selector) { + const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const block = new RegExp(`${esc} \\{([\\s\\S]*?)\\n\\s*\\}`).exec(css); + if (!block) return null; + const m = /grid-template-columns:\s*repeat\((\d+)/.exec(block[1]); + return m ? Number(m[1]) : null; +} + +test('四卦推导按自己有多宽换列,不按视口', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + + // 推导图在右栏里,右栏多宽由左栏与版心说了算,跟视口宽度不是一回事: + // 视口拉宽而左栏也拉宽时,右栏未必跟着变宽。拿视口定列数, + // 宽视口配窄右栏就会把四支挤成参差的一行。 + const box = /\.derive \{([\s\S]*?)\n \}/.exec(client); + assert.ok(box, '找不到 .derive 的样式'); + assert.ok(/container-type: inline-size/.test(box[1]), '推导图没有按自身宽度做容器查询'); + const name = /container-name: ([\w-]+)/.exec(box[1]); + assert.ok(name, '推导图的容器没有起名,@container 认不出问的是谁'); + + const wide = repeatCount(client, '.derive .derive-row'); + assert.equal(wide, 4, `推导图宽时应当四列,实得 ${wide} 列`); + assert.equal(4 % wide, 0, '四列除不尽四个取法,末行会落单'); + + const narrow = new RegExp( + `@container ${name[1]} \\(max-width: (\\d+)px\\)[\\s\\S]*?\\.derive \\.derive-row \\{[\\s\\S]*?repeat\\((\\d+)`, + ).exec(client); + assert.ok(narrow, '推导图没有按容器宽度降列'); + assert.equal(Number(narrow[2]), 2, `推导图窄时应当两列,实得 ${narrow[2]} 列`); + assert.equal(4 % Number(narrow[2]), 0, '两列除不尽四个取法,末行会落单'); + + // 反向钉住:视口媒体查询一律不许碰推导图与八宫。 + // 一碰,列数就又被绑回视口宽度,前面那条容器查询等于白写。 + for (const block of atRuleBlocks(client, 'media')) { + assert.ok( + !/\.(derive|palace)\b/.test(block), + '有 @media 规则改了推导图或八宫的排布,列数被绑回视口宽度了', + ); + } +}); + +test('箭头与卦裹成一格排,本卦横跨整行', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + + // 格子才是排布单位。原先箭头与卦是平级的 flex item,注记长短一差, + // 后面的卦就被挤偏,四列的间距也不相等。 + assert.ok( + /
/.test(client), + '推导格没有把箭头与卦裹进同一格,两者仍是平级的 flex item', + ); + const step = /\.derive \.derive-step \{([\s\S]*?)\n \}/.exec(client); + assert.ok(step, '找不到 .derive-step 的样式'); + assert.ok(/display: flex/.test(step[1]), '一格内部不是 flex'); + assert.ok(/align-items: center/.test(step[1]), '一格内部没有上下居中'); + + // 一行四格,别再用 flex 换行:换行在窄容器下会折成 3+1,末格孤零零居中。 + const row = /\.derive \.derive-row \{([\s\S]*?)\n \}/.exec(client); + assert.ok(row, '找不到 .derive-row 的样式'); + assert.ok(/display: grid/.test(row[1]), '推导行还在用 flex 换行,窄容器下会折成参差的两排'); + assert.ok(!/flex-wrap/.test(row[1]), '推导行还留着 flex-wrap'); + + // 本卦那行只有一格,不跨列就贴到第 1 列,看着像四种取法里的头一个。 + const span = /\.derive \.derive-row > \.unit:only-child \{([\s\S]*?)\n \}/.exec(client); + assert.ok(span, '本卦那一格没有单独定过位置'); + assert.ok(/grid-column: 1 \/ -1/.test(span[1]), '本卦那一格没有横跨整行,会贴在第 1 列'); +}); + +test('注记留两行高、按数据断行,四支箭头才落在同一条线上', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + + const block = /\.derive \.arrow \.how \{([\s\S]*?)\n \}/.exec(client); + assert.ok(block, '找不到注记的样式'); + const style = block[1]; + + const fontSize = Number(/font-size: (\d+)px/.exec(style)[1]); + const lineHeight = Number(/line-height: ([\d.]+)/.exec(style)[1]); + const minHeight = /min-height: ([\d.]+)em/.exec(style); + assert.ok(minHeight, '注记没有定高:互卦那条两行、其余各一行,箭头高度一差就不在一条线上'); + assert.ok( + Number(minHeight[1]) >= 2 * lineHeight - 1e-9, + `注记只留了 ${minHeight[1]}em,装不下两行(需 ${(2 * lineHeight).toFixed(2)}em)`, + ); + + // 一行字要落在两行高的盒子正中。块级元素默认贴顶,一行就会比两行高半行。 + assert.ok( + /display: flex/.test(style) && /align-items: center/.test(style), + '注记不是上下居中排的,一行两行会差半行', + ); + + // DERIVE_HOW 里的 \n 必须真的断行。不写这条,\n 会被折成空格, + // 换行变成靠 max-width 碰运气折出来——改个字号就可能折成一行或三行。 + assert.ok(/white-space: pre-line/.test(style), '注记没有按数据里的 \\n 断行,换行靠碰运气'); + + // 硬断出来的每一段都要放得下,否则会被再折一行,箭头又对不齐。 + // 量法跟排版一致:CJK 一个字 1em,其余按半个字宽算。 + const table = /const DERIVE_HOW = \{([\s\S]*?)\n \};/.exec(client); + assert.ok(table, '找不到取法说明表'); + const notes = [...table[1].matchAll(/\['([^']+)',\s*'([^']+)'\]/g)].map((m) => m[2]); + assert.equal(notes.length, 4, `四种取法应当四条注记,实得 ${notes.length} 条`); + + const maxWidth = Number(/max-width: (\d+)px/.exec(style)[1]); + for (const note of notes) { + for (const seg of note.split('\\n')) { + const em = [...seg].reduce((sum, ch) => sum + (/[⺀-鿿豈-﫿]/.test(ch) ? 1 : 0.5), 0); const need = em * fontSize; + assert.ok( + need <= maxWidth, + `注记「${seg}」要 ${need.toFixed(0)}px,max-width 只有 ${maxWidth}px,会被再折一行,四支箭头就对不齐了`, + ); + } + } +}); + +test('一宫八卦按自己有多宽换列,八格与四格都整除八卦', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + + const box = /\.palace \{([\s\S]*?)\n \}/.exec(client); + assert.ok(box, '找不到 .palace 的样式'); + assert.ok(/container-type: inline-size/.test(box[1]), '八宫名单没有按自身宽度做容器查询'); + const name = /container-name: ([\w-]+)/.exec(box[1]); + assert.ok(name, '八宫名单的容器没有起名,@container 认不出问的是谁'); + + const wide = repeatCount(client, '.palace .palace-row'); + assert.equal(wide, 8, `八宫名单应当八列,实得 ${wide} 列`); + assert.equal(8 % wide, 0, '八宫名单的列数除不尽八卦,末行会落单'); + + const narrow = new RegExp( + `@container ${name[1]} \\(max-width: (\\d+)px\\)[\\s\\S]*?\\.palace \\.palace-row \\{[\\s\\S]*?repeat\\((\\d+)`, + ).exec(client); + assert.ok(narrow, '八宫名单没有按容器宽度降列'); + assert.equal(Number(narrow[2]), 4, `八宫名单窄时应当四列,实得 ${narrow[2]} 列`); + assert.equal(8 % Number(narrow[2]), 0, '四列除不尽八卦,末行会落单'); +}); + +test('版心内边距只有一个来源,顶栏取它的相反数', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + + // 写死 32px 时,1000px 上下的视口要白占掉 64px 宽度, + // 推导图与八宫正是被这一点挤到换行的。 + const defined = client.match(/--app-pad(?![\w-])\s*:/g) ?? []; + assert.equal(defined.length, 1, `--app-pad 定义了 ${defined.length} 次`); + assert.ok( + /--app-pad:\s*clamp\(/.test(client), + '版心内边距是死数,没有跟着视口收放', + ); + + const app = /\.app \{([\s\S]*?)\n \}/.exec(client); + assert.ok(app, '找不到 .app 的样式'); + assert.ok(/padding: var\(--app-pad\)/.test(app[1]), '.app 没用 --app-pad'); + + // 顶栏要拉成整条,就得抵消 .app 的上内边距与左右内边距。 + // 两处各写一个 32 的话,内边距一收放,顶栏底色就不贴视口边了。 + const topbar = /\.topbar \{([\s\S]*?)\n \}/.exec(client); + assert.ok(topbar, '找不到 .topbar 的样式'); + assert.ok( + /calc\(var\(--app-pad\) \* -1\)/.test(topbar[1]), + '顶栏没有取 --app-pad 的相反数,内边距一收放底色就露边', + ); + assert.ok( + !/margin: -?\d+px -\d+px/.test(topbar[1]), + '顶栏的外边距写死了数字,跟 --app-pad 各走各的', + ); + assert.ok(/padding: 18px var\(--app-pad\)/.test(topbar[1]), '顶栏的内边距没有跟着 --app-pad 走'); +}); + +test('解读页左栏不窄过 300px,窄过就会把纳甲挤到右栏去', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + + const reading = /\.reading \{([\s\S]*?)\n \}/.exec(client); + assert.ok(reading, '找不到 .reading 的样式'); + const track = /grid-template-columns:\s*([^;]+);/.exec(reading[1]); + assert.ok(track, '解读页没有分左右两栏'); + + const width = /clamp\((\d+)px/.exec(track[1]); + assert.ok(width, `左栏不是一个随视口收放的区间,写的是:${track[1].trim()}`); + + // 爻行那一格是 34+42+1fr+32+auto 再加四道 12px 的缝,固定部分就吃掉 156px; + // 留给纳甲(干支+六亲+世应+旬空月破,全是 nowrap)的那格还得有 140px 出头。 + // 压到 300 以下,纳甲就撑破格子往右栏探,卦画同时被挤没——阴阳都读不出来。 + const line = /\.gua-line \{([\s\S]*?)\n \}/.exec(client); + assert.ok(line, '找不到 .gua-line 的样式'); + const fixed = [...line[1].matchAll(/(\d+)px/g)] + .map((m) => Number(m[1])) + .filter((n) => n <= 42); + const seams = (line[1].match(/gap:\s*(\d+)px/) ?? [null, 0])[1]; + const chrome = fixed.reduce((a, b) => a + b, 0) + Number(seams) * 4; + + assert.ok( + Number(width[1]) >= 300, + `左栏下限 ${width[1]}px 装不下爻行:固定列与缝就吃掉 ${chrome}px,留给纳甲的还得有 140px 出头`, + ); + assert.ok( + /min-width: 0/.test(/\.gua-line \.rel \{([\s\S]*?)\n \}/.exec(client)[1]), + '纳甲那一格没有 min-width: 0,撑破格子时不会让出位置', + ); + + // 栏间距也跟着视口收放:左栏与版心都在动,缝写死就有一处对不上。 + assert.ok(/gap:\s*clamp\(/.test(reading[1]), '解读页的栏间距是死数'); + + // 窄到这个宽度,两栏并排已经各自装不下了,换单列。 + // 收尾那个分号别省:写成 grid-template-columns: 1fr 的话,1fr 1fr 也照样匹配, + // 断言就替两栏那一条作了证。 + const narrow = atRuleBlocks(client, 'media').filter((b) => b.includes('.reading')); + assert.ok(narrow.length > 0, '解读页没有在窄屏换单列'); + assert.ok( + narrow.some((b) => /grid-template-columns:\s*1fr\s*;/.test(b)), + '解读页在窄屏没有换单列,两栏各自都装不下', + ); +}); + +test('起卦在飞时锁住触发控件,按原值还原,失败也得解锁', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + + // 三条起卦路(每日/时间、数字、铜钱)都走 cast()。原先只有 toss-finish 有个 + // disabled,那是「摇够六次」的规矩;四个起法按钮与「起卦」按钮从来不禁用, + // 而 cast() 自己也没有闸。连点一下会连发两次请求,两卦的推演日志互相顶替。 + // 实测过:双击「时间起卦」,网络里只该出现一次 POST /api/divination/cast。 + const castFn = /async function cast\(payload\) \{([\s\S]*?)\n \}/.exec(client); + assert.ok(castFn, '找不到 cast()'); + const body = castFn[1]; + + assert.ok(/let casting = false;/.test(client), '起卦在飞这件事没有被记下来,或者初始值不是「没在起卦」'); + assert.ok( + /if \(casting\) return false;/.test(body), + 'cast() 没有先问「是不是已经在起卦了」——连点会连发两次请求', + ); + + // 锁的是状态不是某一个按钮:三条起卦路一次锁齐,才不会漏掉哪条。 + const triggers = /function castTriggers\(\) \{([\s\S]*?)\n \}/.exec(client); + assert.ok(triggers, '找不到 castTriggers()'); + for (const id of ['num-submit', 'num-random', 'toss-btn', 'toss-reset', 'toss-finish']) { + assert.ok(triggers[1].includes(`'${id}'`), `${id} 没被锁住,推演那几秒里仍能改动这一卦`); + } + assert.ok(/querySelectorAll\('\[data-method\]'\)/.test(triggers[1]), '四个起法按钮没被锁住'); + + // 解锁按各自原值还原,不是一律解禁。toss-finish 平时就该是禁着的(还没摇够六次), + // 跟着一起解禁等于凭空开出一个没摇够也能「成卦解卦」的入口。 + const lock = /function lockCasting\(\) \{([\s\S]*?)\n \}/.exec(client); + assert.ok(lock, '找不到 lockCasting()'); + assert.ok(/const saved = nodes\.map\(\(node\) => \[node, node\.disabled\]\)/.test(lock[1]), '锁之前没记下各控件原本的 disabled'); + assert.ok( + /for \(const \[node, was\] of saved\) node\.disabled = was;/.test(lock[1]), + '解锁不是按原值还原——toss-finish 会被凭空解禁', + ); + + // 解锁放 finally:出错也必须解开,否则一次网络失败就把整个起卦页永久锁死。 + // 不给 finally 那段加结尾锚点:外层那个捕获组已经把 cast() 末尾的收尾吃掉了。 + const tail = /\}\s*catch \(error\) \{[\s\S]*?\}\s*finally \{([\s\S]*)$/.exec(body); + assert.ok(tail, 'cast() 的 catch 之后没有 finally'); + assert.ok(/unlock\(\)/.test(tail[1]), '解锁没放在 finally 里:起卦失败一次,起卦页就再也点不动了'); + assert.ok(/casting = false;/.test(tail[1]), 'finally 里没有把 casting 复位,下一卦永远起不了'); + + // 返回值要能回答「这一卦到底起成了没有」:数字那条清空输入框全靠它。 + assert.ok(/return true;/.test(body), 'cast() 没有报告「起成了」'); + const submit = /el\('num-submit'\)\.addEventListener\('click', async \(\) => \{([\s\S]*?)\n \}\);/.exec(client); + assert.ok(submit, '找不到数字起卦的提交处理'); + assert.ok( + /const ran = await cast\(/.test(submit[1]), + '数字起卦没接住 cast() 的返回值,不知道这一卦起没起成', + ); + assert.ok( + /if \(!ran\) return;[\s\S]*?el\('num-upper'\)\.value = '';/.test(submit[1]), + '被锁跳过或起卦失败时照样清空输入框——等于替用户把没起成的卦也扔了', + ); +}); + +/** 摇钱那一块要真跑:坏在「谁最后写 disabled」上,源码文本看不出谁盖谁, + 只能让代码自己跑一遍。抠出 syncTossButtons/lockToss/tossOnce/renderToss + 四个真函数,配一套假 DOM 与假 fetch。 */ +function loadTossPanel(client) { + const cut = (start, end) => { + const i = client.indexOf(start); + assert.ok(i >= 0, `客户端里找不到 ${start}`); + const j = client.indexOf(end, i); + assert.ok(j >= 0, `客户端里找不到 ${start} 的结尾`); + return client.slice(i, j + end.length); + }; + const code = [ + cut('function syncTossProgress() {', '\n }'), + cut('function syncTossButtons() {', '\n }'), + cut('function lockToss() {', '\n }'), + cut('async function tossOnce() {', '\n }'), + cut('function renderToss(latest) {', '\n }'), + ].join('\n'); + return code; +} + +function fakeClassList() { + const set = new Set(); + return { add: (c) => set.add(c), remove: (c) => set.delete(c), has: (c) => set.has(c) }; +} + +/** 起一套只够摇钱用的假页面。返回的 buttons 就是断言要看的那几个状态。 */ +function tossPanelDom(client) { + const buttons = {}; + for (const id of ['toss-btn', 'toss-finish', 'toss-reset']) { + buttons[id] = { id, disabled: false }; + } + const coin = { classList: fakeClassList(), textContent: '', offsetWidth: 0 }; + const slot = { classList: fakeClassList(), querySelector: (s) => (s === '.coin' ? coin : null) }; + const coins = { children: [slot, { ...slot, querySelector: slot.querySelector }, { ...slot, querySelector: slot.querySelector }] }; + const tossLog = { + children: [], + replaceChildren() { this.children.length = 0; }, + prepend(node) { this.children.unshift(node); }, + }; + const nodes = { ...buttons, coins, 'toss-log': tossLog, 'toss-fill': { style: {} }, 'toss-count': { textContent: '' } }; + const state = { tosses: [] }; + const announced = []; + const make = (api) => + new Function( + 'el', 'state', 'api', 'announce', 'document', 'setTimeout', + `${loadTossPanel(client)}\nreturn { tossOnce, syncTossButtons, lockToss, renderToss, syncTossProgress };`, + )( + (id) => nodes[id], + state, + api, + (msg) => announced.push(msg), + { createElement: () => ({ innerHTML: '', style: {} }) }, + () => 0, + ); + return { make, buttons, state, announced, fill: nodes['toss-fill'], count: nodes['toss-count'] }; +} + +test('结论整块排在两栏之前:吉凶、缘由、宜忌一次读完,推导在下面', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const cut = (start, end) => { + const i = client.indexOf(start); + assert.ok(i >= 0, `客户端里找不到 ${start}`); + const j = client.indexOf(end, i); + assert.ok(j >= 0, `客户端里找不到 ${start} 的结尾`); + return client.slice(i, j + end.length); + }; + const fn = cut('function renderReading(reading) {', '\n }'); + + // 结论块要挂在那两栏(卦面 + 事实表与推导)之前。从前那句大白话排在右栏第四件, + // 前头压着九行技术事实表、后头跟着八宫名单与四卦推导,混在一堆里认不出来。 + // 断的是「挂上去」这一步而不只是「写出来」:只查结论块那段代码在不在, + // 把 card.append(lead) 删了照样全绿,而页面上根本没有这块。 + const leadAt = fn.indexOf("lead.className = 'verdict-block"); + const appendAt = fn.indexOf('card.append(lead);'); + const twoColAt = fn.indexOf("body.className = 'reading'"); + assert.ok(leadAt > 0, '结果页没有结论块'); + assert.ok(twoColAt > 0, '结果页没有那两栏'); + assert.ok(appendAt > 0, '结论块写出来了却没挂到页面上,页面上根本没有它'); + assert.ok(leadAt < appendAt, '结论块在挂上去之前就声明了,接线顺序反了'); + assert.ok(appendAt < twoColAt, '结论块排在两栏之后,等于又埋回信息堆里'); + + // 块里四样东西按「吉凶 → 缘由 → 怎么算的 → 宜忌」这个次序摆出来。 + const order = [ + ["mark.className = 'verdict-mark'", '吉凶那个大字'], + ['markShort.className = \'verdict-say-short\'', '一句话结论'], + ["say.className = 'verdict-say'", '大白话正文'], + ['onTopic.className = \'verdict-say verdict-on-topic\'', '落到所问之事上的那句'], + ["calc.className = 'verdict-calc'", '怎么算的'], + ["picks.className = 'verdict-picks'", '宜忌'], + ["warn.className = 'verdict-warn'", '提醒'], + ]; + let cursor = -1; + for (const [anchor, what] of order) { + const at = fn.indexOf(anchor); + assert.ok(at > 0, `结论块里没有${what}`); + assert.ok(at > cursor, `${what}排错了位置,该在它前头的那几样之后`); + cursor = at; + } + + // 取象句要真的挂上那个开关。上一版只查「写没写出这句」,把开关改成永假照样全绿, + // 而页面上那句话从此不出现——那正是这一轮最初要解决的事,不能靠肉眼守。 + assert.ok( + /if \(reading\.topicLine\) \{/.test(fn), + '取象句没挂在开关上:认不认得出事类都摆着,或者都摆不出来', + ); + assert.ok( + /onTopic\.textContent = reading\.topicLine;/.test(fn), + '取象句建了元素却没把内容放进去,页面上会是一段空的', + ); + // 大字已经摆着「大吉」,紧跟着那句要是原样搬来就成了「大吉 大吉:可进」, + // 同一个词在同一行里说两遍。冒号前那半要丢掉,只留可执行的那半。 + assert.ok( + /markShort\.textContent = reading\.verdict\.summary\.split\(':'\)/.test(fn), + '大字旁边那句原样搬了 summary,页面上会把「吉凶」那一档连说两遍', + ); + + // 宜忌与提醒要跟着结论走,不能还留在页面里——那是最容易被跳过的地方, + // 而且留着就是同一句话在屏幕上说两遍。数的是真正渲染的那几处(`.map`、 + // `textContent`),不是那几个 `length > 0` 的空判。 + assert.equal( + (fn.match(/advice\.suitable\.map/g) || []).length, + 1, + '「宜」在页面上渲染了不止一处:要么重复,要么还挂在页面末尾', + ); + assert.equal( + (fn.match(/advice\.avoid\.map/g) || []).length, + 1, + '「忌」在页面上渲染了不止一处', + ); + assert.equal( + (fn.match(/textContent = reading\.advice\.caution/g) || []).length, + 1, + '提醒在页面上渲染了不止一处', + ); + + // 同一句话不许在页首与右栏各说一遍。 + assert.ok( + !/summary\.className = 'text-line'/.test(fn), + '右栏还留着那句结论,跟页首的结论块成了同一句话两遍', + ); + + // 事实表里那行「生克」是依据不是结论,不能再用箭头写成「所以是」。 + assert.ok( + /生克本为\$\{reading\.verdict\.relationVerdict\}/.test(fn), + '事实表的生克行没标出这是生克那一层的断语,与页首的总分对不上会被当成算错', + ); + assert.ok( + !/reading\.verdict\.key\}\s*→\s*\$\{reading\.verdict\.label\}/.test(fn), + '事实表仍把生克直接箭头连到总分,那正是同屏两个吉凶的老毛病', + ); +}); + +test('写了所问何事,结论就落到那件事上;换问法只换取象,不换吉凶', () => { + const cast = castByNumbers(5, 2); + const now = new Date('2026-09-30T10:00:00+08:00'); + const at = (question) => buildReading(cast, { now, question }); + + // 问跳槽与问进货拿到同一卦:吉凶必须一模一样(问事只定事类与应期,不改卦体), + // 但取象句要各自说到那件事上。少了后半句,同一句「体卦水克用卦火」就能对付 + // 所有问题,等于没答所问。 + const job = at('这工作该不该跳'); + const goods = at('这批货该不该进'); + assert.equal(job.verdict.label, goods.verdict.label, '换个问法把卦的吉凶改了'); + assert.equal(job.verdict.score, goods.verdict.score, '换个问法把总分改了'); + assert.ok(job.topicLine, '写了所问何事,结论却没有落到那件事上'); + assert.ok(goods.topicLine, '写了所问何事,结论却没有落到那件事上'); + assert.notEqual(job.topicLine, goods.topicLine, '问跳槽与问进货拿到的还是同一句'); + assert.match(job.topicLine, /事业功名/, '问跳槽,结论没说到事业上去'); + assert.match(job.topicLine, /这份前程/, '问跳槽,结论里没有那件事本身'); + assert.match(goods.topicLine, /财运/, '问进货,结论没说到财运上去'); + assert.match(goods.topicLine, /这笔进项/, '问进货,结论里没有那笔钱本身'); + + // 九个事类每一个都要有自己的那个东西,不能有两个事类共用一句话。 + const questions = ['这工作该不该跳', '这批货该不该进', '他还会不会喜欢我吗', '这婚要不要定', + '这病要不要去治', '这次考试能过吗', '这房子该不该买', '这官司打得赢吗', '这趟出差顺利吗']; + const lines = new Set(); + for (const question of questions) { + const reading = at(question); + assert.ok(reading.topicLine, `${question} 认出了事类却没给出取象句`); + lines.add(reading.topicLine); + } + assert.equal(lines.size, questions.length, '九类问法里有取象句重复的,说明那句还是笼统的'); + + // 认不出事类就不摆这句,也不拿别的话来凑:宁可没有,不要错的。 + const vague = at('嗯'); + assert.equal(vague.topicLine, undefined, '认不出事类却硬给了一句取象'); + assert.equal(vague.topic, null, '「嗯」竟认出了事类'); + + // 取象句的强弱跟着总分那一档走,不跟着生克那一层的教科书断语走。 + // 这三个卦的生克完全相同,都是体克用、生克本为小吉,只因体卦逢的月令不同, + // 总分落在大吉、平、吉三档。要是取象句跟着生克走,这三句会一模一样。 + const lucky = buildReading(castByNumbers(1, 4), { now, question: '这工作该不该跳' }); + const flat = buildReading(castByNumbers(1, 19), { now, question: '这工作该不该跳' }); + const mild = buildReading(castByNumbers(6, 7), { now, question: '这工作该不该跳' }); + for (const [name, reading, want] of [['大吉', lucky, '大吉'], ['平', flat, '平'], ['吉', mild, '吉']]) { + assert.equal(reading.verdict.label, want, `${name} 那一卦的总分改了`); + assert.equal(reading.verdict.relationVerdict, '小吉', `${name} 那一卦的生克层变了,样本就不作数了`); + } + assert.match(lucky.topicLine, /是顺的/, `大吉却说「${lucky.topicLine}」,强弱说反了`); + assert.match(flat.topicLine, /看不出强弱/, `平卦却说「${flat.topicLine}」,强弱说反了`); + assert.match(mild.topicLine, /是顺的/, `吉卦却说「${mild.topicLine}」,强弱说反了`); + // 顺的那一档收同一句话是有意的:取象句分的是「顺 / 看不出强弱 / 不顺」三档, + // 不是五个吉凶档——大吉与吉的分量差在页首那个大字上,这句只管落到什么事上。 + // 所以这里要断的是平卦那句与吉卦那句确实不同,而不是三句两两不同。 + assert.notEqual(flat.topicLine, lucky.topicLine, '平卦与大吉卦的取象句是同一句'); + assert.equal(lucky.topicLine, mild.topicLine, '同为「顺」的一档却收了两句话'); +}); + +test('Agent 自报的事类压过关键词,关键词那条路留作退路', () => { + const cast = castByNumbers(5, 2); + const now = new Date('2026-09-30T10:00:00+08:00'); + const at = (question, topic) => buildReading(cast, { now, question, topic }); + + // 这句是关键词表接不住的——表上都是「感情」「恋爱」「前任」这类词, + // 「他对我还有没有真心」一个都不含。页面那条路只能认不出,Agent 读了原话能认。 + const vague = '他对我还有没有真心'; + assert.equal(detectTopic(vague), null, '这句本就该认不出事类,下面才验得了 Agent 那条路'); + assert.equal(at(vague).topic, null, '没给 topic 时不该凭空认出一类'); + assert.equal(at(vague).topicSource, null, '没定下事类却说定了一个'); + assert.equal(at(vague).topicLine, undefined, '没定事类却给了取象句'); + + const told = at(vague, 'love'); + // 下面几处都走 ?.:topic 为 null 时直接读 .key 会抛 TypeError,而 TypeError 不带 + // 消息,按关键词判「这条断言钉住没有」的就永远对不上——报红得落在自己的消息上。 + assert.equal(told.topic?.key, 'love', 'Agent 报了感情,事类却没落上去'); + assert.equal(told.topic?.label, '感情', '事类落错了类'); + assert.equal(told.topicSource, 'explicit', '明明是 Agent 给的,却说成关键词认出来的'); + assert.match(told.topicLine, /这段关系/, '感情类的事没说成那段关系'); + + // 关键词那条路是退路不是主路:原话明明会撞出事业,Agent 报了财运就得听 Agent 的。 + const job = '这工作该不该跳'; + assert.equal(detectTopic(job).key, 'career', '这句本该撞出事业功名,下面才验得了显式优先'); + const overridden = at(job, 'wealth'); + assert.equal(overridden.topic?.key, 'wealth', 'Agent 报的事类被关键词盖回去了'); + assert.equal(overridden.topicSource, 'explicit', '显式优先没生效'); + + // 给了个不存在的键:当作没给,退回关键词,而不是静悄悄地认成别的类。 + const bogus = at(job, '不存在的类'); + assert.equal(bogus.topic?.key, 'career', '认不出的键没退回关键词'); + assert.equal(bogus.topicSource, 'detected', '退回关键词后来源仍说成 Agent 给的'); + + // 事类只改应期与取象,吉凶一个字都不许跟着动——这条是这个参数存在的底线。 + for (const topic of [undefined, 'wealth', 'love', 'journey', 'dispute']) { + const one = at(job, topic); + assert.equal(one.verdict.label, at(job).verdict.label, `给了 topic=${topic} 把卦的吉凶改了`); + assert.equal(one.verdict.score, at(job).verdict.score, `给了 topic=${topic} 把总分改了`); + assert.equal(one.verdict.key, at(job).verdict.key, `给了 topic=${topic} 把体用生克改了`); + } + + // 九类各认得出来,且都是真类——枚举漏一个键,Agent 按提示挑的那个就落空。 + const keys = TOPIC_CLASSES.map((t) => t.key); + for (const key of keys) { + assert.equal(at('随便问问', key).topic?.key, key, `给了 topic=${key} 却没落到这一类`); + } + assert.equal(at('随便问问', '').topic, null, '空串也被当成了一个事类'); + assert.equal(at('随便问问', 42).topic, null, '数字也被当成了一个事类'); +}); + +test('白话块把体用旺衰翻成「你、那件事、你此刻的劲」', () => { + const cast = castByNumbers(5, 2); + const now = new Date('2026-09-30T10:00:00+08:00'); + const reading = buildReading(cast, { now, question: '这工作该不该跳', topic: 'career' }); + const plain = reading.plain; + + // 术语那一层:体卦是你、用卦是那件事、旺衰是你此刻的劲。这三样是整个卦理里 + // 最抽象的地方,白话块不改口径,只换说法——说漏一样,用户就得回去查词。 + assert.match(plain.why, new RegExp(`体卦${reading.structure.body.name}${reading.structure.body.element}是你`), '白话没说清体卦是谁'); + assert.match(plain.why, new RegExp(`用卦${reading.structure.use.name}${reading.structure.use.element}是那件事`), '白话没说清用卦是谁'); + for (const bad of ['体卦', '用卦', '旺衰', '类神', '月令']) { + assert.ok( + !plain.why.replace(`体卦${reading.structure.body.name}${reading.structure.body.element}`, '') + .replace(`用卦${reading.structure.use.name}${reading.structure.use.element}`, '') + .includes(bad), + `白话里还留着术语「${bad}」没翻`, + ); + } + // 上一条只查「术语没漏」,漏查了反面:把 ${verdict.vitality} 原样塞回去, + // 一个术语词都不带,检查照样全绿。旺衰译没译,得按这一卦实际落在哪一档去查。 + const VITALITY_SAID = { + 旺: '最有力气', 相: '有人托着', 休: '使不上劲', 囚: '受制', 死: '气力最弱', + }; + assert.ok( + plain.why.includes(VITALITY_SAID[reading.verdict.vitality]), + `白话没把旺衰「${reading.verdict.vitality}」翻成人话:${plain.why}`, + ); + + assert.match(plain.ask, /这工作该不该跳/, '白话没把用户问的那句话接回来'); + assert.match(plain.topic, /事业功名/, '白话没说清归的哪一类'); + assert.ok(!plain.topic.includes('类神'), '白话里还留着术语「类神」没翻'); + assert.equal(plain.verdict.includes(reading.verdict.label), true, '白话的结论里没有吉凶那一档'); + assert.equal(plain.onTopic, reading.topicLine, '白话里的取象句与取象句本身对不上'); + assert.match(plain.timing, /月/, '白话没说时间'); + assert.match(plain.actions, /该做的是:/, '白话没给该做的'); + assert.match(plain.actions, /别做的是:/, '白话没给别做的'); + assert.equal(plain.caution, reading.advice.caution, '白话的提醒与宜忌那一份对不上'); + + // 没定事类时:取象那句整块不摆,不拿别的话凑;时间照旧给,问的那句话照样接回来。 + const blank = buildReading(cast, { now, question: '明天的会顺利吗' }).plain; + assert.equal(blank.onTopic, '', '没定事类却硬给了一句取象'); + assert.match(blank.topic, /按那件事本身算/, '没定事类时没说清按什么算'); + assert.ok(blank.timing.length > 0, '没定事类就把时间也省了'); + assert.match(blank.ask, /明天的会顺利吗/, '写了问题却没在白话里接回来'); + + // 压根没写问题时另一句:说清是按时辰看的,别让用户以为漏传了他的问事。 + const noAsk = buildReading(cast, { now }).plain; + assert.equal( + noAsk.ask, + '你没写具体问什么,我按起卦当时的时辰给你看这一卦。', + '没写问题时白话没说明是按时辰看的', + ); + assert.equal(noAsk.topic, '你问的事不在那九类里,事类先空着,时间上按那件事本身算。', '没写问题时说得像认不出事类'); + + // 生克那层与总分不一致时(体克用本是生克小吉,体卦逢死地总分落回「平」), + // 差价就在白话里讲开——用户最容易把这当成算错了。 + const mismatched = [3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25] + .map((n) => buildReading(castByNumbers(n, 2), { now, question: '这工作该不该跳' })) + .find((r) => r.verdict.relationVerdict !== r.verdict.label); + assert.ok(mismatched, '扫了十二组上下卦还没撞出生克层与总分不一致的卦,这段没法验'); + assert.ok( + mismatched.plain.why.includes(`照两人之间的关系本该是${mismatched.verdict.relationVerdict}`), + `生克层与总分差了一档,白话里却没讲开:${mismatched.plain.why}`, + ); +}); + +test('两处版本号与清单一致,且是能排的版本号', async () => { + const manifest = JSON.parse(await readFile(new URL('../.minimax-plugin/plugin.json', import.meta.url), 'utf8')); + const mcp = await readFile(new URL('../miniapp/node/mcp/divination-http.mjs', import.meta.url), 'utf8'); + const serverVersion = mcp.match(/version: '([\d.]+)'/)?.[1]; + + // 清单给 Host 看的是它,serverInfo 给 initialize 的调用方看的是另一个。改版本时 + // 两处各写各的,Host 报 1.1.0 而 initialize 报 1.0.0,从外面根本看不出来。 + assert.ok(serverVersion, 'MCP 的 serverInfo 里没有版本号'); + assert.equal(serverVersion, manifest.version, `清单是 ${manifest.version},serverInfo 却是 ${serverVersion}`); + assert.match(manifest.version, /^\d+\.\d+\.\d+$/, `版本号「${manifest.version}」不是 x.y.z`); + assert.equal(manifest.name, 'chinese-divination', '清单里的插件名与目录名对不上'); +}); + +test('四种起法经 MCP 都真起得成卦:铜钱那一路曾经每一次都报「必须是 6 到 9 之间的整数」', async () => { + const { handleMcpRequest } = await import('../miniapp/node/mcp/divination-http.mjs'); + const cast = async (args) => { + let raw = ''; + await handleMcpRequest({ + response: { writeHead() { return this; }, end(chunk) { raw += chunk; return this; } }, + body: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'divination_cast', arguments: args } }, + }); + return JSON.parse(raw).result; + }; + + // 前面那些 MCP 端到端只打过 numbers。time 与 daily 碰巧是对的,coins 不是: + // tossCoins() 掷一次返回一个 { sum, coins } 对象,castByCoins() 要的是六次结果 + // 组成的数组,对象递进去 .length 是 undefined,每一次调用都撞在同一个报错上。 + // 引擎那一层的 castByCoins 有几十条测试,全是直接喂数组——所以这条断路从头到尾 + // 没被任何一条断言碰到过。四法一起打,才算把这一类断路堵住。 + for (const method of ['time', 'daily', 'coins', 'numbers']) { + const args = method === 'numbers' ? { method, upper: 3, lower: 8 } : { method }; + const result = await cast({ ...args, topic: 'career' }); + assert.equal(result.isError, undefined, `${method} 经 MCP 起卦报错了:${result.content[0].text}`); + + const sc = result.structuredContent; + assert.ok(sc.hexagram?.name, `${method} 起完了却没给出卦名`); + assert.ok(sc.hexagram.order >= 1 && sc.hexagram.order <= 64, `${method} 的卦序不在 1–64:${sc.hexagram.order}`); + assert.ok(sc.verdict?.label, `${method} 起完了却没给出吉凶`); + assert.ok(['大吉', '吉', '平', '凶', '大凶'].includes(sc.verdict.label), `${method} 的吉凶「${sc.verdict.label}」不在五档里`); + assert.ok(result.content[0].text.includes('【大白话】'), `${method} 的正文末尾没有大白话那段`); + // 四条起法都得把事类往下传。少传一条,那条起法上 Agent 自报的事类就被悄悄丢了, + // 而它照样起得出卦、照样给出吉凶——不钉这一条根本发现不了。 + assert.equal(sc.topic?.key, 'career', `${method} 这一路把 Agent 报的事类丢了`); + assert.match(result.content[0].text, /由 Agent 指定/, `${method} 这一路没标明事类是 Agent 定的`); + + // 铜钱这一路要多验一层:起卦依据那行得真列出六次掷钱的结果,不是三次也不是零个。 + if (method === 'coins') { + const line = result.content[0].text.split('\n').find((l) => l.startsWith('【起卦依据】')); + assert.ok(line, '铜钱起卦的依据那一行没写出来'); + const tossed = line.match(/掷钱\s+((?:[6-9](?:\s*·\s*)?){6})/); + assert.ok(tossed, `铜钱起卦没有列出六个点数:${line}`); + } + } + + // 时间起卦说的是「以当下时辰成卦」。可「当下」得有个能验的凭据:起卦依据里写着 + // 「月 · 日」,它必须是今天的那一天。月令旺衰那一层不算——那是从 buildReading 的 + // now 算的,把 castByTime 的参数换成 1970 年它照样是今月的金,验不出来。 + // 跨零点时前后各取一次日期,两头都算过,免得撞上换日那一秒。 + const today = new Date(); + const timed = await cast({ method: 'time' }); + const dayMark = (d) => `月 · 日 ${d.getMonth() + 1} + ${d.getDate()}`; + const timedBasis = timed.content[0].text.split('\n').find((l) => l.startsWith('【起卦依据】')); + assert.ok( + [dayMark(today), dayMark(new Date())].some((mark) => timedBasis.includes(mark)), + `时间起卦用的不是今天:「${timedBasis}」`, + ); + + // 铜钱该摇得出不同的卦。断的是卦名而不是「有没有变卦」——掷出 6 或 9 就会有变卦, + // 拿「有变卦」做判据的话,随手一掷也可能两种都出现,这条断言就成了概率的。 + const names = new Set(); + for (let i = 0; i < 12; i += 1) { + names.add((await cast({ method: 'coins' })).structuredContent.hexagram.name); + } + assert.ok(names.size > 1, `连着摇了十二次,只摇出${[...names].join('、')}一个卦`); +}); + +test('MCP 让 Agent 自报事类,并在正文末尾补一段大白话', async () => { + const { handleMcpRequest } = await import('../miniapp/node/mcp/divination-http.mjs'); + const call = async (method, params) => { + let raw = ''; + await handleMcpRequest({ + response: { writeHead() { return this; }, end(chunk) { raw += chunk; return this; } }, + body: { jsonrpc: '2.0', id: 1, method, params }, + }); + return JSON.parse(raw).result; + }; + const cast = (args) => call('tools/call', { name: 'divination_cast', arguments: args }); + + // 枚举得真列给 Agent 看:它得知道能挑哪九类,否则这个参数等于没有。 + const listed = await call('tools/list', {}); + const schema = listed.tools.find((t) => t.name === 'divination_cast').inputSchema; + const topicProp = schema.properties.topic; + assert.deepEqual( + topicProp.enum, + TOPIC_CLASSES.map((t) => t.key), + 'topic 的枚举与九类事类对不上,Agent 按提示挑会挑空', + ); + assert.equal(schema.properties.topic.type, 'string', 'topic 不是字符串'); + assert.match(topicProp.description, /不改变卦体吉凶/, '参数说明没告诉 Agent 事类不改吉凶'); + assert.match(topicProp.description, /省略/, '参数说明没告诉 Agent 拿不准可以省略'); + assert.ok( + !schema.properties.question.description.includes('定事类'), + 'question 的说明还写着由它定事类,与新加的 topic 参数自相矛盾', + ); + + // 关键词接不住的那句,Agent 报了类就落得上去。 + const question = '他对我还有没有真心'; + assert.equal(detectTopic(question), null, '这句本该认不出事类'); + const told = await cast({ method: 'numbers', upper: 5, lower: 2, question, topic: 'love' }); + const toldText = told.content[0].text; + + // 结构先验:白话段接进去了、在正文末尾、在免责之前。放在内容断言前头—— + // 白话整段没接的时候,内容那几条会先红,报出来的消息跟「没接进去」不是一回事。 + assert.ok(toldText.includes('【大白话】'), '正文末尾没有大白话那一段'); + assert.ok(toldText.includes('【起卦依据】'), '正文里没有起卦依据那一段'); + assert.ok( + toldText.indexOf('【大白话】') > toldText.indexOf('【起卦依据】'), + '白话插在正文中间,用户读不到最后', + ); + assert.ok( + toldText.indexOf('【提示】') > toldText.indexOf('【大白话】'), + '免责排在白话前头,顺序反了', + ); + + assert.equal(told.structuredContent.topic?.key, 'love', 'Agent 报的事类没进结构化结果'); + assert.match(toldText, /由 Agent 指定/, '正文没说是谁定的事类'); + assert.match(toldText, /这段关系/, '白话没说到那段关系上'); + + const guessed = await cast({ method: 'numbers', upper: 5, lower: 2, question: '这工作该不该跳' }); + assert.equal(guessed.structuredContent.topic?.key, 'career', '不给 topic 时关键词那条路没兜住'); + assert.match(guessed.content[0].text, /按关键词认出/, '正文没说清是关键词认出来的'); + + // 给了不存在的键:当场报错让 Agent 重挑,不能静悄悄当没给。 + const bad = await cast({ method: 'numbers', upper: 5, lower: 2, question, topic: '财运' }); + assert.equal(bad.isError, true, '给了不存在的键却照常起了一卦'); + assert.match(bad.content[0].text, /未知的事类/, '报错没点名是事类不对'); + assert.match(bad._meta.recovery, /省略/, '报错没告诉 Agent 拿不准可以省略'); + + // 白话段里一句空话都不许留:没定事类时那一句是空的,留下来就是一个空行。 + const vague = await cast({ method: 'numbers', upper: 5, lower: 2, question: '明天的会顺利吗' }); + const vaguePlain = vague.content[0].text.split('【大白话】')[1].split('【提示】')[0]; + assert.ok(!vaguePlain.includes('\n\n'), '白话里留了空行,没定事类的那一句该整块不出现'); + assert.ok(vaguePlain.trimStart().startsWith('你问的是'), '白话第一句没接上用户问的那句话'); + assert.ok(!vaguePlain.includes('卦里说的就是'), '没定事类却摆了取象句'); + + const plainText = toldText.slice(toldText.indexOf('【大白话】')); + assert.match(plainText, new RegExp(question), '白话段里没有用户问的那句话'); + assert.match(plainText, /是你/, '白话段里没把体卦翻成「你」'); + assert.match(plainText, /是那件事/, '白话段里没把用卦翻成「那件事」'); + + // 提示语也得教模型照着白话讲,否则这段照样被它用术语复述掉。 + const init = await call('initialize', {}); + assert.match(init.instructions, /【大白话】/, 'initialize 的提示语没提大白话那段'); +}); + +test('SKILL 把「先问清这件事」与新参数都交代给 Agent', async () => { + const skill = await readFile(new URL('../skills/divination/SKILL.md', import.meta.url), 'utf8'); + + // 这一份是 Agent 的入口文档,不是给人读的说明书。改了它,模型那一侧就跟着变, + // 而仓库自检只查文件在不在——不钉住的话,一次「顺手精简」就能把下面几条悄悄弄没。 + // ① topic 九个键:不写,Agent 不会去填,事类就退回关键词,「他对我还有没有真心」 + // 那类问法照旧认不出,而页面这边没有别的入口。 + for (const topic of TOPIC_CLASSES) { + assert.ok(skill.includes(topic.key), `SKILL 没告诉 Agent 有 ${topic.key} 这一类事`); + } + // ② 末了那段白话:不写,Agent 照着断语用术语复述一遍,白话块白做。 + assert.match(skill, /大白话/, 'SKILL 没交代末了那段白话是给用户听的'); + // ③ 起卦前先问清:这是这一版补进去的关键一步。见问就起卦,得到的解读挂不到 + // 用户真实的处境上,抽象且苍白——正是要避免的那种结果。断的是那三问本身, + // 不断标题:把「起卦前:先把这件事问清楚」改成「起卦前」,一段话还在那儿。 + const beforeCast = skill.slice(0, skill.indexOf('## 起卦三步')); + assert.ok(beforeCast.length > 100, 'SKILL 里找不到「起卦前」那一段'); + assert.match(beforeCast, /问清|问清楚/, 'SKILL 没要求起卦前把事情问清'); + // 断的是那三条 bullet 本身。开头那句「你不知道他在纠结什么、已经走到哪一步、 + // 最怕的是哪一头」里也有同样的字眼,断那几个词的话,删掉整条问题照样全绿。 + for (const ask of ['到底是什么事', '已经走到哪一步', '最怕的是哪一头']) { + assert.ok(beforeCast.includes(`- **${ask}**`), `SKILL 里的「起卦前」没问「${ask}」,只剩半截引导`); + } + // ④ 免责声明不许被顺手改掉。 + assert.match(skill, /仅供娱乐,无实际预测功能/, 'SKILL 里的免责说明被改掉了'); +}); + +test('掷钱与成卦解卦互为反面:摇满六次就禁掷钱、开成卦解卦', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + + // 进度条与「已摇 n / 6 爻」是同一件事的两面:宽度由爻数算出来,不是另写 + // 一份百分比。两边各写各的,条走满了字还停在 3/6,或者反过来。 + { + const dom = tossPanelDom(client); + const panel = dom.make(async () => ({ coins: [true], sum: 7 })); + const widths = []; + for (let n = 0; n <= 6; n += 1) { + dom.state.tosses = Array.from({ length: n }, () => 7); + panel.syncTossProgress(); + widths.push(dom.fill.style.width); + assert.equal(dom.count.textContent, `已摇 ${n} / 6 爻`, `摇了 ${n} 爻,计数没跟着走`); + assert.equal( + dom.fill.style.width, + `${(n / 6) * 100}%`, + `摇了 ${n} 爻,条宽与爻数对不上`, + ); + } + assert.equal(widths[0], '0%', '一爻没摇,条上就已经有进度了'); + assert.equal(widths[6], '100%', '摇满六次,条还没走完'); + // 逐级递增,不许中途回落或跳过:一次摇钱只该推进一格。 + for (let n = 1; n < 6; n += 1) { + assert.ok( + parseFloat(widths[n]) > parseFloat(widths[n - 1]), + `第 ${n} 次摇完进度反而没往前走:${widths[n - 1]} → ${widths[n]}`, + ); + } + } + + // 静置态先定下来:0 摇 → 掷钱可点、成卦解卦禁着。这与 HTML 里初始写的 disabled 一致。 + { + const dom = tossPanelDom(client); + dom.make(async () => ({ coins: [], sum: 0 })).syncTossButtons(); + assert.equal(dom.buttons['toss-btn'].disabled, false, '还没摇钱就不让掷钱,起点就错了'); + assert.equal(dom.buttons['toss-finish'].disabled, true, '一爻没摇就能「成卦解卦」,这个入口是空的'); + } + + // 一路摇到第六次。这是这一轮真正坏掉的地方:renderToss 算出「摇够了该禁掷钱」, + // 紧接着 tossOnce 的 finally 又硬写了一次解禁,把刚算出来的状态盖回可点, + // 结果两个按钮同时可点——既能再摇第七爻,又能就着六爻成卦,两头都不作数。 + const dom = tossPanelDom(client); + const { buttons, state } = dom; + const panel = dom.make(async () => ({ coins: [true, false, true], sum: 7 })); + + for (let i = 1; i <= 5; i += 1) { + await panel.tossOnce(); + assert.equal(state.tosses.length, i, `第 ${i} 次掷钱没有落进卦里`); + assert.equal(buttons['toss-btn'].disabled, false, `才摇了 ${i} 爻就不让掷了`); + assert.equal(buttons['toss-finish'].disabled, true, `才摇了 ${i} 爻就放「成卦解卦」过`); + // 进度条是掷钱这条路上自己带的更新,不靠别处补调:摇一次,界面就得长一格。 + assert.equal(dom.count.textContent, `已摇 ${i} / 6 爻`, `掷完第 ${i} 次,进度计数没跟上`); + assert.equal(dom.fill.style.width, `${(i / 6) * 100}%`, `掷完第 ${i} 次,进度条没跟上`); + } + + await panel.tossOnce(); + assert.equal(state.tosses.length, 6, '第六次掷钱没有落进卦里'); + assert.equal( + buttons['toss-btn'].disabled, + true, + '摇满六次后「掷钱」还是可点:能摇出第七爻,多出来的这一爻既排不进卦也撤不回', + ); + assert.equal(buttons['toss-finish'].disabled, false, '摇满六次了「成卦解卦」还禁着,六爻白摇'); + + // 第七次不能摇进去。这条比按钮状态更要紧:多出来的一爻既排不进六爻的卦里, + // 也撤不回——卦上多一爻,解读会跟着偏。 + await panel.tossOnce(); + assert.equal(state.tosses.length, 6, '摇满六次之后还能再摇,卦里多出了第七爻'); + + // 「重来」走的是另一条路:它直接清空列表重画,不经过任何在飞中的收尾, + // 所以按钮状态必须由重画本身算出来,不能指望上一次收尾顺带带出来。 + state.tosses = []; + panel.renderToss(); + assert.equal(buttons['toss-btn'].disabled, false, '重摇之后「掷钱」还禁着,这一卦没法重来'); + assert.equal(buttons['toss-finish'].disabled, true, '重摇之后「成卦解卦」还开着,卦里一爻都没有'); + // 重来是最容易漏的一条:爻清空了,条还满着,读的人会以为六爻还在。 + assert.equal(dom.count.textContent, '已摇 0 / 6 爻', '重摇之后进度计数还停在满格'); + assert.equal(dom.fill.style.width, '0%', '重摇之后进度条没有退回空'); + + // 两个按钮严格互斥:任何一爻数下都不该同时可点。 + for (const n of [0, 1, 3, 5, 6]) { + state.tosses = Array.from({ length: n }, () => 7); + panel.syncTossButtons(); + assert.notEqual( + buttons['toss-btn'].disabled, + buttons['toss-finish'].disabled, + `摇了 ${n} 爻时两个按钮同时可点或同时禁着,掷钱与成卦解卦没有互为反面`, + ); + } + + // 在飞的时候要按住「重来」:飞到一半点重来,已经在飞的那一爻会落进刚被清空的 + // 列表,卦里就多出一爻看不见来源的东西。 + let release; + const held = new Promise((resolve) => { release = resolve; }); + const slow = tossPanelDom(client); + const running = slow.make(async () => { + await held; + return { coins: [true, true, false], sum: 8 }; + }); + const flying = running.tossOnce(); + assert.equal(slow.buttons['toss-btn'].disabled, true, '一次摇钱在飞,「掷钱」还能点,连点会连摇两次'); + assert.equal(slow.buttons['toss-reset'].disabled, true, '一次摇钱在飞,「重来」还能点,在飞的那一爻会落进刚被清空的列表'); + release(); + await flying; + assert.equal(slow.buttons['toss-btn'].disabled, false, '摇完一次就没法再掷,整页锁死了'); + assert.equal(slow.buttons['toss-reset'].disabled, false, '摇完一次「重来」还禁着,没法重摇'); + + // 摇钱失败也要解开:一次网络失败就把这一页锁死,用户只能刷新。 + const broken = tossPanelDom(client); + const failing = broken.make(async () => { throw new Error('取不到卦'); }); + await failing.tossOnce(); + assert.equal(broken.buttons['toss-btn'].disabled, false, '一次摇钱失败就把「掷钱」永久禁用了'); + assert.equal(broken.buttons['toss-reset'].disabled, false, '一次摇钱失败就把「重来」永久禁用了'); + assert.equal(broken.announced.length, 1, '摇钱失败没有报给用户'); + // 量的必须是 state.tosses,不是卦象日志的 DOM 条数:失败时 renderToss 压根没跑, + // 日志当然是空的,量它等于什么都没量。 + assert.equal(broken.state.tosses.length, 0, '摇钱失败却往卦里写了一爻'); +}); + +test('八卦环在起卦那一拍里转得肉眼看得见', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const budget = Number(/const CASTING_HOLD_MS = (\d+);/.exec(client)[1]); + assert.ok(budget, '找不到起卦后的停留时长'); + const rings = [ + ['外环', /animation: baguaSpin (\d+)s/.exec(client)], + ['内环', /animation: baguaSpinBack (\d+)s/.exec(client)], + ]; + for (const [name, hit] of rings) { + assert.ok(hit, `找不到${name}的转速`); + const period = Number(hit[1]); + // 整段起卦就这么几秒,转速却按分钟算的,看着就等于没动 + const deg = (budget / 1000 / period) * 360; + assert.ok(deg >= 30, `${name} ${period}s 一圈,停留期间只转 ${deg.toFixed(0)}°,等于没动`); + } +}); + +/* ---------- 爻辞 ---------- */ + +const YAO_POSITIONS = ['初', '二', '三', '四', '五', '上']; + +/** 拆出「爻题」「爻辞」;格式不符返回 null。 */ +function parseYao(entry) { + const match = /^(初|上)([九六]):(.+)$|^([九六])([二三四五]):(.+)$/.exec(entry); + if (!match) return null; + const atTop = match[1] !== undefined; + return { + number: atTop ? match[1] : match[5], + polarity: atTop ? match[2] : match[4], + text: (atTop ? match[3] : match[6]).trim(), + }; +} + +test('爻辞六十四卦每卦六条,共 384 条', () => { + assert.equal(HEXAGRAM_LIST.length, 64); + let count = 0; + for (let order = 1; order <= 64; order += 1) { + const rows = hexagramYaoTexts(order); + assert.ok(rows, `第 ${order} 卦没有爻辞`); + assert.equal(rows.length, 6, `第 ${order} 卦的爻辞不是六条`); + count += rows.length; + } + assert.equal(count, 384); + assert.equal(hexagramYaoTexts(0), null); + assert.equal(hexagramYaoTexts(65), null); +}); + +test('爻题标对了爻位,阴阳与卦象逐位吻合', () => { + for (let order = 1; order <= 64; order += 1) { + const hexagram = hexagramByOrder(order); + hexagramYaoTexts(order).forEach((entry, index) => { + const parsed = parseYao(entry); + assert.ok(parsed, `第 ${order} 卦第 ${index + 1} 爻格式不对:${entry}`); + assert.equal(parsed.number, YAO_POSITIONS[index], `第 ${order} 卦第 ${index + 1} 爻位标签错:${entry}`); + assert.ok(parsed.text.length > 0, `第 ${order} 卦第 ${index + 1} 爻辞为空`); + // 阳爻称九、阴爻称六,错一位就说明这一卦的数据串了行 + const isYang = hexagram.key[index] === '1'; + assert.equal(parsed.polarity, isYang ? '九' : '六', `${hexagram.name} ${parsed.number} 与卦象阴阳不符:${entry}`); + }); + } +}); + +test('爻辞不残留繁体', () => { + const TRADITIONAL = '龍貞無見萬與東車馬鳥魚長門風飛貴進遠連覺語說'; + for (let order = 1; order <= 64; order += 1) { + for (const entry of hexagramYaoTexts(order)) { + for (const char of TRADITIONAL) { + assert.ok(!entry.includes(char), `第 ${order} 卦爻辞残留繁体「${char}」:${entry}`); + } + } + } +}); + +test('名句锚定,改一个字就报红', () => { + const ANCHORS = [ + [1, 1, '初九:潜龙勿用。'], + [1, 5, '九五:飞龙在天,利见大人。'], + [2, 1, '初六:履霜,坚冰至。'], + [23, 6, '上九:硕果不食,君子得舆,小人剥庐。'], + [38, 6, '上九:睽孤,见豕负涂,载鬼一车,先张之弧,后说之弧,匪寇婚媾。往,遇雨则吉。'], + [63, 5, '九五:东邻杀牛,不如西邻之禴祭,实受其福。'], + [64, 1, '初六:濡其尾,吝。'], + ]; + for (const [order, position, expected] of ANCHORS) { + assert.equal(lineText(order, position), expected); + } + assert.equal(lineText(1, 0), null); + assert.equal(lineText(1, 7), null); +}); + +test('已/巳、乾/干各有其字,不随繁简转换走样', () => { + // 损初九是「已」(已经),革六二是「巳」(地支),两处曾被同一个来源弄反 + assert.equal(lineText(41, 1), '初九:已事遄往,无咎,酌损之。'); + assert.equal(lineText(49, 2), '六二:巳日乃革之,征吉,无咎。'); + // 噬嗑的「乾」是「干」的通假,乾卦的「乾乾」表刚健,两处不能一并转成「干」 + assert.equal(lineText(21, 4), '九四:噬乾胏,得金矢,利艰贞,吉。'); + assert.equal(lineText(1, 3), '九三:君子终日乾乾,夕惕若,厉,无咎。'); +}); + +test('排盘每一爻都带着爻辞,爻题随爻位走', () => { + const cases = [...SAMPLES().map(([, cast]) => cast), castByCoins([7, 7, 7, 7, 7, 7])]; + for (const cast of cases) { + const reading = buildReading(cast); + const expected = hexagramYaoTexts(reading.hexagram.order); + assert.equal(reading.lines.length, 6); + reading.lines.forEach((line, index) => { + assert.equal(line.text, expected[index]); + assert.equal(line.text, lineText(reading.hexagram.order, line.position)); + // 爻题初/上爻作「初九」,中间爻作「九二」,两种笔顺都要对上爻位与阴阳 + assert.ok(line.title.includes(YAO_POSITIONS[index]), `爻位标签错:${line.title}`); + assert.ok(line.title.includes(line.value === 1 ? '九' : '六'), `阴阳标签错:${line.title}`); + }); + } +}); + +test('解卦洞察里只露动爻那一条爻辞', () => { + let withMoving = 0; + let withoutMoving = 0; + // 六爻皆静的卦由 [7,7,7,7,7,7] 造:六个七全是少阳,乾为天,无动爻 + const cases = [...SAMPLES().map(([, cast]) => cast), castByCoins([7, 7, 7, 7, 7, 7])]; + for (const cast of cases) { + const reading = buildReading(cast); + const insight = reading.insights.find((item) => item.title === '动爻爻辞'); + if (reading.movingLines.length === 0) { + assert.equal(insight, undefined, '六爻皆静时不该出现动爻爻辞'); + withoutMoving += 1; + continue; + } + withMoving += 1; + assert.ok(insight, '有动爻却没有爻辞'); + assert.equal(insight.text.split(';').length, reading.movingLines.length); + for (const line of reading.movingLines) { + assert.ok(insight.text.includes(line.text), '断语没有引动爻的爻辞'); + } + // 位置紧随卦象总断,不排在末尾 + assert.equal(reading.insights[0].title, '卦象总断'); + assert.equal(reading.insights[1].title, '动爻爻辞'); + } + assert.ok(withMoving > 0, '样本里没有一个带动爻的卦'); + assert.ok(withoutMoving > 0, '样本里没有一个六爻皆静的卦'); +}); + +test('卦盘只把动爻那一条爻辞露出来', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + assert.ok(/\.yao-line \{/.test(client), '缺少爻辞样式'); + assert.ok(/className = 'yao-line serif anim-rise'/.test(client), '卦盘没有渲染爻辞块'); + // 六爻全列会把卦盘压成字墙;这里必须按动爻过滤 + assert.ok( + /lines \? lines\.filter\(\(line\) => line\.moving && line\.text\) : \[\]/.test(client), + '卦盘没有按动爻过滤爻辞', + ); + assert.ok(/动爻爻辞<\/span>/.test(client), '爻辞块缺少标题'); +}); + +/* ---------- 小象传 ---------- */ + +test('小象传六十四卦每卦六条,共 384 条', () => { + let count = 0; + for (let order = 1; order <= 64; order += 1) { + const rows = hexagramXiangTexts(order); + assert.ok(rows, `第 ${order} 卦没有小象传`); + assert.equal(rows.length, 6, `第 ${order} 卦的小象传不是六条`); + count += rows.length; + } + assert.equal(count, 384); + assert.equal(hexagramXiangTexts(0), null); + assert.equal(hexagramXiangTexts(65), null); +}); + +test('象传的爻题与爻辞逐条一致,阴阳与卦象吻合', () => { + for (let order = 1; order <= 64; order += 1) { + const hexagram = hexagramByOrder(order); + const yao = hexagramYaoTexts(order); + hexagramXiangTexts(order).forEach((entry, index) => { + const parsed = parseYao(entry); + assert.ok(parsed, `第 ${order} 卦第 ${index + 1} 爻象传格式不对:${entry}`); + assert.equal(parsed.number, YAO_POSITIONS[index]); + assert.equal(parsed.text.length > 0, true, `第 ${order} 卦第 ${index + 1} 爻象传为空`); + // 爻题必须与爻辞逐字相同,否则两处文本已经错位 + const title = entry.slice(0, entry.indexOf(':')); + assert.equal(title, yao[index].slice(0, yao[index].indexOf(':')), `第 ${order} 卦第 ${index + 1} 爻题与爻辞不符`); + assert.equal(parsed.polarity, hexagram.key[index] === '1' ? '九' : '六', `${hexagram.name} ${parsed.number} 与卦象阴阳不符`); + }); + } +}); + +test('象传不残留繁体', () => { + const TRADITIONAL = '龍貞無見萬與東車馬鳥魚長門風飛貴進遠連覺語說難願詳暉試誰諸備傷剛極陽當義聰聽絕積窮竄賢賤辭辯際順類飽馴愛憊'; + for (let order = 1; order <= 64; order += 1) { + for (const entry of hexagramXiangTexts(order)) { + for (const char of TRADITIONAL) { + assert.ok(!entry.includes(char), `第 ${order} 卦象传残留繁体「${char}」:${entry}`); + } + } + } +}); + +test('对校时改掉的字,不许退回某一版的写法', () => { + const ANCHORS = [ + // 底本句尾混进一个 markdown 星号,且多出底本没有的改写 + [5, 2, '九二:需于沙,衍在中也。虽小有言,以吉终也。'], + [5, 5, '九五:酒食,贞吉,以中正也。'], + // 底本漏了「吉」,并把「渝安贞吉」读断了 + [6, 4, '九四:复即命,渝安贞吉,不失也。'], + // 底本多出一句《易传》本没有的解说 + [10, 3, '六三:眇能视,不足以有明也。跛能履,不足以与行也。咥人之凶,位不当也。'], + [17, 6, '上六:拘系之,上穷也。'], + [24, 1, '初九:不远之复,以修身也。'], + [36, 3, '九三:南狩之志,乃大得也。'], + // 两个来源都错:大有九四该用「尫」,与爻辞同;困六三「蒺藜」与「不祥」分属两源 + [14, 4, '九四:匪其尫,无咎,明辨晰也。'], + [47, 3, '六三:据于蒺藜,乘刚也。入于其宫,不见其妻,不祥也。'], + [60, 2, '九二:不出门庭凶,失时极也。'], + ]; + for (const [order, position, expected] of ANCHORS) { + assert.equal(lineXiang(order, position), expected); + } + assert.equal(lineXiang(1, 0), null); + assert.equal(lineXiang(1, 7), null); +}); + +test('排盘每一爻都带着象传,爻题与爻辞同源', () => { + const cases = [...SAMPLES().map(([, cast]) => cast), castByCoins([7, 7, 7, 7, 7, 7])]; + for (const cast of cases) { + const reading = buildReading(cast); + const expected = hexagramXiangTexts(reading.hexagram.order); + reading.lines.forEach((line, index) => { + assert.equal(line.xiang, expected[index]); + assert.equal(line.xiang, lineXiang(reading.hexagram.order, line.position)); + assert.equal(line.xiang.slice(0, line.xiang.indexOf(':')), line.title); + }); + } +}); + +test('动爻爻辞一段里,爻辞在前、象传在后', () => { + const cases = [...SAMPLES().map(([, cast]) => cast), castByCoins([7, 7, 7, 7, 7, 7])]; + let withMoving = 0; + for (const cast of cases) { + const reading = buildReading(cast); + const insight = reading.insights.find((item) => item.title === '动爻爻辞'); + if (reading.movingLines.length === 0) { + assert.equal(insight, undefined); + continue; + } + withMoving += 1; + assert.ok(insight); + const expected = reading.movingLines + .map((line) => `${line.text} 象曰:${line.xiang.slice(line.xiang.indexOf(':') + 1)}`) + .join(';'); + assert.equal(insight.text, expected); + assert.equal(reading.insights[1].title, '动爻爻辞'); + } + assert.ok(withMoving > 0); +}); + +test('卦盘把象传排在爻辞下一行', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + assert.ok(/\.yao-line em \{/.test(client), '缺少象传的样式'); + assert.ok(/象曰:\$\{escapeHtml\(body\(line\.xiang\)\)\}/.test(client), '卦盘没有渲染象传'); + assert.ok(/function body\(entry\)/.test(client), '缺少去爻题前缀的辅助函数'); +}); + +/* ---------- 彖传 ---------- */ + +test('彖传六十四卦每卦一条,且都挂进了卦表', () => { + let count = 0; + for (let order = 1; order <= 64; order += 1) { + const text = tuanText(order); + assert.ok(text, `第 ${order} 卦没有彖传`); + assert.ok(text.trim().length > 0, `第 ${order} 卦的彖传为空`); + assert.equal(hexagramByOrder(order).tuan, text, `第 ${order} 卦的卦表里没有带上彖传`); + count += 1; + } + assert.equal(count, 64); + assert.equal(tuanText(0), null); + assert.equal(tuanText(65), null); + assert.ok(HEXAGRAM_LIST.every((item) => typeof item.tuan === 'string' && item.tuan.length > 0)); +}); + +test('彖传不残留繁体', () => { + const TRADITIONAL = '龍貞無見萬與東車馬鳥魚長門風飛貴進遠連覺語說統應瀆聖況罰薦顒設電獄篤輝麗穀氣風晝嚴澤揚廟揜飪驚懼邇靜勸財續湯繘踰'; + for (let order = 1; order <= 64; order += 1) { + for (const char of TRADITIONAL) { + assert.ok(!tuanText(order).includes(char), `第 ${order} 卦彖传残留繁体「${char}」`); + } + } +}); + +test('名篇锚定与对校订正', () => { + // 乾:两版一作「保和大和」一作「保合太和」,通行本作太和;「品物流形」后该收句 + assert.equal(tuanText(1), '大哉乾元,万物资始,乃统天。云行雨施,品物流形。大明终始,六位时成,时乘六龙以御天。乾道变化,各正性命,保合太和,乃利贞。首出庶物,万国咸宁。'); + // 蒙:底本作「初筮告」,另一版误作「初噬告」 + assert.ok(tuanText(4).includes('初筮告')); + // 小畜:底本有「健而巽」四字,另一版漏 + assert.ok(tuanText(9).includes('健而巽')); + // 贲:另一版把夹注「(刚柔交错)」混进了正文 + assert.ok(!tuanText(22).includes('刚柔交错')); + // 革:底本作「革而信之」,且「巳日」与六二爻辞同 + assert.ok(tuanText(49).includes('革而信之')); + assert.ok(tuanText(49).includes('巳日乃孚')); +}); + +test('彖传与爻辞同源:革的「巳日」两处一致', () => { + assert.ok(lineText(49, 2).startsWith('六二:巳日乃革之')); + assert.ok(tuanText(49).includes('巳日乃孚')); +}); + +test('卦盘与卦库都按「卦辞 → 彖传 → 象辞」的次序排出', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + assert.ok(/\.text-line \.tuan \{/.test(client), '缺少彖传的样式'); + for (const [label, pattern] of [ + ['卦盘', /judgment[^]*?class="tuan">\$\{escapeHtml\(hexagram\.tuan\)\}[^]*?hexagram\.image/], + ['卦库', /item\.judgment[^]*?class="tuan">\$\{escapeHtml\(item\.tuan\)\}[^]*?item\.image/], + ]) { + assert.ok(pattern.test(client), `${label}没有按卦辞、彖传、象辞的次序排`); + } + const server = await readFile(new URL('../miniapp/node/server.mjs', import.meta.url), 'utf8'); + assert.ok(/tuan: hexagram\.tuan,/.test(server), '卦库接口没有带出彖传'); + const mcp = await readFile(new URL('../miniapp/node/mcp/divination-http.mjs', import.meta.url), 'utf8'); + assert.ok(/`彖传:\$\{item\.tuan\}`/.test(mcp), 'MCP 查卦没有输出彖传'); + assert.ok(/item\.tuan\.includes\(query\)/.test(mcp), 'MCP 查卦没有按彖传检索'); +}); + +test('起卦返回的本卦与变卦都带着彖传', () => { + // 卦库页与解读页读的是两条不同的数据路径:卦库走 server 自己的字段列表, + // 解读页走 reading.hexagram。少一处,另一处就会把彖传渲染成 undefined。 + const cases = [...SAMPLES().map(([, cast]) => cast), castByCoins([7, 7, 7, 7, 7, 7])]; + let changed = 0; + for (const cast of cases) { + const reading = buildReading(cast); + assert.equal( + reading.hexagram.tuan, + tuanText(reading.hexagram.order), + `${reading.hexagram.name} 的本卦没带上彖传`, + ); + if (reading.changed) { + changed += 1; + assert.equal( + reading.changed.tuan, + tuanText(reading.changed.order), + `${reading.changed.name} 的变卦没带上彖传`, + ); + } + } + assert.ok(changed > 0, '样本里一个变卦都没有,这条断言等于没验'); +}); + +test('十二辟卦恰好十二卦,且与卦表逐一对得上', () => { + // 这条最要紧:卦气的整套推论都建立在这张表上,表错一位,后面全错。 + assert.equal(TWELVE_MESSAGES.length, 12); + const matched = HEXAGRAM_LIST.filter((item) => hexagramQi(item.key)); + assert.equal(matched.length, 12, `六十四卦里只认出 ${matched.length} 卦属于十二辟卦`); + for (const lord of TWELVE_MESSAGES) { + const fromTable = hexagramByKey(lord.key); + assert.ok(fromTable, `${lord.name} 的爻象 ${lord.key} 在卦表里找不到`); + assert.equal(fromTable.name, lord.name, `${lord.branch} 月主卦的卦名与卦表不符`); + const qi = hexagramQi(lord.key); + assert.equal(qi.name, lord.name); + assert.equal(qi.phase, lord.phase); + } +}); + +test('卦气按月支推移,子月复、亥月坤,十二个月不重不漏', () => { + // 传统:复主子月、临主丑月……乾主巳月、姤主午月,直到坤主亥月。 + const expected = [ + '地雷复', '地泽临', '地天泰', '雷天大壮', '泽天夬', '乾为天', + '天风姤', '天山遁', '天地否', '风地观', '山地剥', '坤为地', + ]; + const actual = TWELVE_MESSAGES.map((item) => item.name); + assert.deepEqual(actual, expected); + for (let branch = 0; branch < 12; branch += 1) { + assert.equal(monthQi(branch)?.name, expected[branch], `${branch} 月的主卦不对`); + } + assert.equal(monthQi(12), null); + assert.equal(monthQi(-1), null); +}); + +test('消长各六:复至乾为息,姤至坤为消', () => { + const xi = TWELVE_MESSAGES.filter((item) => item.phase === '息').map((item) => item.short); + const xiao = TWELVE_MESSAGES.filter((item) => item.phase === '消').map((item) => item.short); + assert.deepEqual(xi, ['复', '临', '泰', '大壮', '夬', '乾']); + assert.deepEqual(xiao, ['姤', '遁', '否', '观', '剥', '坤']); + // 息卦阳爻由一长到六,消卦阴爻由一长到六,两边的进度是对称的。 + assert.deepEqual(xi.map((short) => hexagramQi(TWELVE_MESSAGES.find((m) => m.short === short).key).yangCount), [1, 2, 3, 4, 5, 6]); +}); + +test('非辟卦不硬套卦气', () => { + // 屯、师、谦、豫下卦虽有两三个阳爻,却不是消息卦的形状,不能算成复或临。 + for (const name of ['水雷屯', '地水师', '地山谦', '雷地豫', '水火既济', '泽雷随']) { + const item = hexagramByKey(HEXAGRAM_LIST.find((h) => h.name === name).key); + assert.equal(hexagramQi(item.key), null, `${name} 被误认成十二辟卦了`); + } +}); + +test('断语里的卦气段说明当月主卦,并给出本卦的位置', () => { + const reading = buildReading(castByNumbers(1, 1), { now: new Date(2026, 8, 30) }); + const gua = reading.insights.find((item) => item.title === '卦气 · 当令主卦'); + assert.ok(gua, '断语里没有卦气这一段'); + assert.match(gua.text, /月当令主卦为/, '卦气段没有点出当月主卦'); + const inSeptember = buildReading(castByNumbers(1, 1), { now: new Date(2026, 8, 30) }); + const inJanuary = buildReading(castByNumbers(1, 1), { now: new Date(2026, 0, 20) }); + assert.notEqual( + inSeptember.insights.find((i) => i.title === '卦气 · 当令主卦').text, + inJanuary.insights.find((i) => i.title === '卦气 · 当令主卦').text, + '不同月份的卦气段不该逐字相同', + ); + assert.match(inJanuary.insights.find((i) => i.title === '卦气 · 当令主卦').text, /丑月当令主卦为地泽临/); +}); + +test('起卦带着卦气坐标,供页面画消长环', () => { + // 消长环画的是「走到哪一格」,数据得从 reading 出来,不能由页面自己再推一遍, + // 否则表改一处、环上还留着旧数。 + const reading = buildReading(castByNumbers(1, 1), { now: new Date(2026, 8, 30) }); + assert.ok(reading.qi, 'reading 上没有卦气坐标'); + assert.equal(reading.qi.branch, monthPillar(2026, 9, 30).branch); + assert.equal(reading.qi.lord, monthQi(reading.qi.branch).name); + assert.equal(reading.qi.lordShort, monthQi(reading.qi.branch).short); + assert.ok(['息', '消'].includes(reading.qi.phase)); + // 本卦非辟卦时卦气坐标照样要有——当月主卦照样要画在环上 + const shiZhan = buildReading(castByNumbers(3, 4), { now: new Date(2026, 5, 10) }); + assert.ok(shiZhan.qi, '卦气坐标不该因为本卦非辟卦就消失——当月主卦照样要画'); + assert.equal(shiZhan.qi.lord, monthQi(shiZhan.qi.branch).name); + assert.equal(shiZhan.qi.self, null, '师卦不是十二辟卦,不该被标上位置'); +}); + +test('消长环按十二格画出,并与卦气数据对得上', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + assert.ok(/\.qiring \{/.test(client), '消长环没有样式'); + assert.ok(/function qiRingSvg\(qi\)/.test(client), '没有消长环的绘制函数'); + // 上一条只验了「函数存在」,函数写死了不调用照样能过——这里再钉一次调用点。 + assert.ok(/if \(reading\.qi\) \{[\s\S]{0,320}?qiRingSvg\(reading\.qi\)/.test(client), '解读页没有真正把卦气坐标交给消长环绘制'); + assert.ok(/if \(!qi\) return ''/.test(client), '消长环没有做空值保护'); + // 十二格,一格一卦,名字必须与 guaqi.mjs 的表一致 + const ring = /const QI_RING = \[([\s\S]*?)\];/.exec(client); + assert.ok(ring, '客户端没有十二辟卦的对照表'); + const shorts = [...ring[1].matchAll(/s: '([^']+)'/g)].map((m) => m[1]); + assert.deepEqual(shorts, TWELVE_MESSAGES.map((m) => m.short), '环上的卦名与卦气表对不上'); + const branches = [...ring[1].matchAll(/b: '([^']+)'/g)].map((m) => m[1]); + assert.deepEqual(branches, ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥']); + assert.ok(/prefers-reduced-motion: reduce[\s\S]*?\.qiring \.now-sector \{\s*animation: none/.test(client), '消长环没有尊重系统的减少动效设置'); +}); + +test('成卦盘随推演长出,动爻最后才标红', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + assert.ok(/\.casting-hex \{/.test(client), '成卦盘没有样式'); + assert.ok(/function growCastingHex\(box, reading\)/.test(client), '没有成卦盘的生长逻辑'); + // 钉在调用点,不是钉在符号存在 + assert.ok( + /const stage = showCasting\(\);[\s\S]{0,400}?growCastingHex\(stage\.hex, data\.reading\)/.test(client), + '起卦流程没有驱动成卦盘生长', + ); + // 动爻必须等六爻长齐再点。早一点就泄底了——起卦的意义正是先成卦、后定动爻。 + assert.ok( + /if \(index === order\.length - 1\) \{[\s\S]{0,220}?if \(other\.line\?\.moving\)/.test(client), + '动爻不是等六爻长齐后才标红', + ); + // 成卦盘只画一爻都不长就等于没画 + assert.ok(/if \(lines\.length === 0\) return;/.test(client), '成卦盘没有处理空卦体'); + assert.ok(/prefers-reduced-motion: reduce[\s\S]{0,200}?\.casting-hex \.grow \{[\s\S]{0,120}?transition: none/.test(client), '成卦盘没有尊重系统的减少动效设置'); +}); + +test('起卦那一拍留得够长,成卦盘能在等待之内长齐', async () => { + // 成卦盘分到的时间不能超过整段等待,否则推演还没画完就跳结果。 + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const budget = Number(/const CASTING_BUDGET_MS = (\d+);/.exec(client)[1]); + const waitMs = Number(/const CASTING_HOLD_MS = (\d+);/.exec(client)[1]); + assert.ok(waitMs >= budget, `等待 ${waitMs}ms 短于推演预算 ${budget}ms,画到一半就会被切掉`); + // 余量还得真能容下末行落定与切页,两者相等就是刚好卡在最后一帧上切走。 + assert.ok(waitMs - budget >= 200, `等待 ${waitMs}ms 只比预算多 ${waitMs - budget}ms,末行来不及落定`); + // 成卦盘取推演预算的一部分,再除以六爻:下界 × 6 仍须落在等待之内 + const step = /const stepMs = Math\.max\((\d+), Math\.floor\(\(CASTING_BUDGET_MS \* ([\d.]+)\) \/ order\.length\)\)/.exec(client); + assert.ok(step, '找不到成卦盘每爻的间隔'); + const minStep = Number(step[1]); + const share = Number(step[2]); + assert.ok(minStep * 6 <= waitMs, `六爻按最小间隔 ${minStep}ms 排下来要 ${minStep * 6}ms,超过等待 ${waitMs}ms`); + assert.ok(share <= 1, '成卦盘分到的时间占比不合法'); + assert.ok(budget * share <= waitMs, `成卦盘要画到 ${budget * share}ms,超过等待 ${waitMs}ms`); +}); + +test('四卦推导图把互、变、错、综的取法画出来', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + assert.ok(/\.derive \{/.test(client), '推导图没有样式'); + assert.ok(/function deriveBlock\(reading\)/.test(client), '没有推导图的绘制函数'); + // 钉在调用点,不是钉在符号存在。上一版只匹配「赋值到 append」, + // 结果 if (false && derive) 也能过——这一版把判断本身也圈进来。 + assert.ok( + /if \(derive\) \{[\s\S]{0,160}?box\.innerHTML = derive;[\s\S]{0,80}?right\.append\(box\)/.test(client), + '解读页没有真正把推导图插进页面', + ); + // 四种取法都要真的排进 steps,光在 DERIVE_HOW 里写个说明不算数 + for (const kind of ['mutual', 'changed', 'opposite', 'inverted']) { + assert.ok( + new RegExp(`\\['${kind}', reading\\.${kind},`).test(client), + `推导图没有把${kind}排进去`, + ); + } + // 六爻皆静时变卦为 null,步骤表要能把它滤掉,否则会画出一个空卦 + assert.ok(/\.filter\(\(\[, target\]\) => Boolean\(target\)\)/.test(client), '推导图没有滤掉不存在的变卦'); + // 四种取法一个都不能少,图注要写清怎么取 + for (const [kind, how] of [ + ['mutual', '取二三四为下卦'], + ['changed', '动爻阴阳反转'], + ['opposite', '六爻阴阳全反'], + ['inverted', '六爻上下倒置'], + ]) { + assert.ok(new RegExp(`${kind}: \\['${kind === 'mutual' ? '互卦' : kind === 'changed' ? '变卦' : kind === 'opposite' ? '错卦' : '综卦'}', '${how}`).test(client), `推导图少了${how}的说明`); + } +}); + +test('八宫那一列排在四卦推导前头,八格按世次一路排下来', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + assert.ok(/\.palace \{/.test(client), '八宫名单没有样式'); + assert.ok(/function palaceBlock\(jingfang\)/.test(client), '没有八宫名单的绘制函数'); + // 钉在调用点,不是钉在符号存在。函数写死了不调用照样能过。 + assert.ok( + /if \(palace\) \{[\s\S]{0,200}?box\.innerHTML = palace;[\s\S]{0,80}?right\.append\(box\)/.test(client), + '解读页没有真正把八宫名单插进页面', + ); + // 次序:先认清自己在哪一宫的哪一级,再看梅花那四种推法。排反了读的人就得回头找。 + // 找的是调用点那一行,不是函数定义——定义在文件里排得更前, + // 拿定义去比顺序,判出来的先后跟页面上真正的先后不是一回事。 + const palaceAt = client.indexOf('const palace = palaceBlock(reading.jingfang)'); + const deriveAt = client.indexOf('const derive = deriveBlock(reading)'); + assert.ok(palaceAt > 0, '解读页没有把京房数据交给八宫名单绘制'); + assert.ok(deriveAt > 0, '解读页没有画四卦推导'); + assert.ok(palaceAt < deriveAt, '八宫名单排在四卦推导后头了'); + // 名单是跟着卦走的:拿的是本卦那一宫的名单,不是写死某八格 + assert.ok(/const roster = jingfang && jingfang\.roster;/.test(client), '八宫名单没有读同宫名单'); + assert.ok(/if \(!roster \|\| roster\.length === 0\) return null;/.test(client), '八宫名单没有做空值保护'); + // 抬头写清宫与五行,看图的人知道自己站在哪 + assert.ok(/escapeHtml\(jingfang\.palaceName\)/.test(client), '八宫抬头没写宫名'); + assert.ok(/escapeHtml\(jingfang\.element\)/.test(client), '八宫抬头没写五行'); + // 名次照名次原样写,图注里「游」「归」怎么来的也说了,别让那句话空着 + assert.ok(/游魂退到四爻、归魂退到三爻/.test(client), '八宫图注没解释游魂归魂为什么叫这个名字'); + assert.ok(/一世到五世世爻逐爻上移/.test(client), '八宫图注没说明世次怎么爬'); +}); + +test('八宫与四卦推导共用小卦样式,没被 .derive 又 scope 回去', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + // 这一组是 miniGua() 画出来的小卦,两处都在用。挂回 .derive 下面, + // 八宫那八格就只剩默认样式:没有朱砂变爻,没有加粗游归,图注说的话全落空。 + // 把样式表里所有选择器原样收下来,再一条条问「有没有这一条」。 + // 不能只找「以 X 结尾」的那条规则——`.palace .unit.odd .unit-tag` 也以 `.unit-tag` 结尾, + // 先撞上它,就永远轮不到真正那条 `.unit-tag`。 + const selectors = [...client.matchAll(/^\s*([^\n{]+?)\s*\{/gm)].map((m) => m[1].trim()); + for (const selector of ['.derive-head', '.unit', '.unit-name', '.unit-tag', '.mini-gua', '.mini-line', '.mini-line.touched i']) { + assert.ok(selectors.includes(selector), `${selector} 这条规则没了,八宫那八格会掉样式`); + assert.ok(!selectors.includes(`.derive ${selector}`), `${selector} 被挂回 .derive 底下了`); + } + // 反过来,推导图自己的布局(箭头、那一行)就该留在 .derive 里, + // 放出去会让八宫那块也去吃本该只有箭头才有的排版 + for (const selector of ['.derive .arrow', '.derive .derive-row', '.derive .derive-body']) { + assert.ok(selectors.includes(selector), `${selector} 这条规则没了`); + assert.ok(!selectors.includes(selector.replace('.derive ', '')), `${selector} 被提出去了,八宫那块会跟着吃这条排版`); + } + // 变过的爻染朱砂这条,八宫那八格靠它才看得出次序 + const touched = /\.mini-line\.touched i \{([\s\S]*?)\}/.exec(client); + assert.ok(touched, '小卦没有变爻的样式'); + assert.ok(/var\(--seal\)/.test(touched[1]), '变过的爻没有染朱砂'); +}); + +test('本卦那一格的朱砂压得过游魂归魂的加粗', async () => { + // 十六卦的本卦自身就落在游魂或归魂上,那一格同时挂「就是你」和「这是例外级」两个标记。 + // 两条规则特异性相同时按书写顺序决胜,朱砂那条必须写在后面, + // 否则本卦恰好是游归时只剩个框、字反倒是灰的,看着像别人的一格。 + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const selfAt = client.indexOf('.palace .unit.self .unit-tag'); + const oddAt = client.indexOf('.palace .unit.odd .unit-tag'); + assert.ok(oddAt > 0, '游魂归魂那两格没有加粗'); + assert.ok(selfAt > 0, '本卦那一格的世次没有染朱砂'); + assert.ok(selfAt > oddAt, '朱砂那条压在游归加粗前面,本卦落在游魂归魂上时字就成灰的了'); + // 两条得是同一个特异性(各三个类),否则先后顺序根本不起作用 + assert.ok( + /\.palace \.unit\.odd \.unit-tag \{[\s\S]{0,400}?\.palace \.unit\.self \.unit-tag \{/.test(client), + '这两条不是同级,先写后写都一样', + ); + // 框是朱砂的,格子上确实同时挂了两个标记 + assert.ok(/\.palace \.unit\.self \{[\s\S]*?border: 1px solid var\(--seal\)/.test(client), + '本卦那一格没有朱砂框'); + const start = client.indexOf('function palaceBlock(jingfang)'); + const block = client.slice(start, client.indexOf('\n function ', start + 10)); + assert.ok(/const isHere = slot\.stage === jingfang\.stage;/.test(block), '本卦那格没按世次认'); + assert.ok(/const isOdd = slot\.stage === '游魂' \|\| slot\.stage === '归魂';/.test(block), + '游魂归魂两格没认出来'); + assert.ok(/'self self-unit'/.test(block) && /'odd'/.test(block), '两个标记没有一起挂到格子上'); +}); + +test('八宫那八格只标变过的爻,不标世爻', async () => { + // 世爻在左边卦盘上已经朱砂框出来了,这里再标一遍是两份要人核对的账。 + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const start = client.indexOf('function palaceBlock(jingfang)'); + const block = client.slice(start, client.indexOf('\n function ', start + 10)); + assert.ok(block, '找不到八宫名单的绘制函数'); + assert.ok(/miniGua\(slot\.key, slot\.flips\)/.test(block), '八宫那八格没有把变过的爻传给小卦'); + assert.ok(!block.includes('slot.shi'), '八宫那八格不该再标世爻,左边卦盘上已经标了'); + // 顺带盯着四卦推导那一格没被改:它传的是 null,意思是本卦不标任何爻 + const deriveAt = client.indexOf('function deriveBlock(reading)'); + const derive = client.slice(deriveAt, client.indexOf('\n function ', deriveAt + 10)); + assert.ok(/miniGua\(base\.key, null\)/.test(derive), '四卦推导的本卦那格被误标了爻'); +}); + +test('推导图的四卦,取法本身经得起核', () => { + // 图只是把既有结果画出来,所以要保证画出去的与算出来的一致:互卦取二三四、 + // 三四五,错卦全反,综卦倒置——这四条若有一条画错,图就在骗人。 + for (const [name, cast] of SAMPLES()) { + const reading = buildReading(cast); + const self = reading.hexagram.key; + const mutual = reading.mutual.key; + assert.equal(mutual.slice(0, 3), self.slice(1, 4), `${name}:互卦下卦不是二三四爻`); + assert.equal(mutual.slice(3, 6), self.slice(2, 5), `${name}:互卦上卦不是三四五爻`); + assert.equal(reading.opposite.key, [...self].map((c) => (c === '1' ? '0' : '1')).join(''), `${name}:错卦不是六爻全反`); + assert.equal(reading.inverted.key, [...self].reverse().join(''), `${name}:综卦不是上下倒置`); + if (reading.changed) { + const moving = new Set(reading.movingLines.map((l) => l.position)); + for (let i = 0; i < 6; i += 1) { + const position = i + 1; + if (moving.has(position)) { + assert.notEqual(reading.changed.key[i], self[i], `${name}:第${position}爻动了却没变`); + } else { + assert.equal(reading.changed.key[i], self[i], `${name}:第${position}爻没动却变了`); + } + } + } + } +}); + +test('查卦默认省去彖传,要原文时显式要', async () => { + // 目标里写着「上手成本低」。连查八卦带彖传近五百字,多半用不上,却把要紧的 + // 话埋在中间。默认给 brief,要原文再传 detail="full"——不是砍内容,是排序。 + const mcp = await readFile(new URL('../miniapp/node/mcp/divination-http.mjs', import.meta.url), 'utf8'); + + // 档位得在工具描述里讲清楚,Agent 才知道什么时候要 full + assert.ok( + /enum: \['brief', 'full'\], default: 'brief'/.test(mcp), + 'detail 档位没有在 inputSchema 里声明', + ); + assert.ok( + /detail="full"/.test(mcp) && /彖传原文/.test(mcp), + '工具描述没有告诉 Agent 什么时候该要彖传', + ); + // brief 档下彖传整条不能出现,full 档下必须出现 + assert.ok( + /detail === 'full' \? `彖传:\$\{item\.tuan\}` : null/.test(mcp), + '彖传没有跟着 detail 档位走', + ); + // 省下的量要真的省:brief 得比 full 短出四成以上,否则这档白设 + const tuanTotal = HEXAGRAM_LIST.slice(0, 8).reduce((sum, item) => sum + item.tuan.length, 0); + const briefTotal = HEXAGRAM_LIST.slice(0, 8) + .reduce((sum, item) => sum + item.judgment.length + item.image.length, 0); + assert.ok( + briefTotal < tuanTotal * 0.6, + `brief 只省了 ${Math.round((1 - briefTotal / tuanTotal) * 100)}%,不够抵一次参数传递`, + ); + + // 上面全是读源码——把默认档翻成 full 时照样全绿。这一条真跑一次端到端, + // 确认不传参数时确实走 brief。 + const { handleMcpRequest } = await import('../miniapp/node/mcp/divination-http.mjs'); + const ask = async (args) => { + let raw = ''; + const response = { + writeHead() { return this; }, + end(chunk) { raw += chunk; return this; }, + }; + await handleMcpRequest({ + response, + body: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'divination_hexagram_lookup', arguments: args }, + }, + }); + return JSON.parse(raw).result; + }; + + const byDefault = await ask({ query: '谦' }); + const byFull = await ask({ query: '谦', detail: 'full' }); + const shortText = byDefault.content[0].text; + const longText = byFull.content[0].text; + assert.equal(byDefault.structuredContent.detail, 'brief', '不传 detail 时不是 brief 档'); + assert.equal(byFull.structuredContent.detail, 'full', '传 detail="full" 没生效'); + assert.ok(!shortText.includes('彖传:'), 'brief 档里仍带着彖传原文'); + assert.ok(longText.includes('彖传:'), 'full 档里没有彖传原文'); + assert.ok(shortText.length < longText.length, 'brief 档没有比 full 档短'); +}); + +// ── 京房一层:八宫、纳甲、六亲、世应 ────────────────────────────────────── + +test('八宫卦序由爻变推出,六十四卦与传世卦序逐一对上', async () => { + // 传世八宫卦序是查来的,但本包不抄表——由八纯卦按《京氏易传》的爻变规则推。 + // 这条把推导结果跟传世表硬对一遍:规则一改,这里立刻红。 + const { jingfang, palaceOf } = await import('../miniapp/node/jingfang.mjs'); + const expected = { + 乾: ['乾为天', '天风姤', '天山遁', '天地否', '风地观', '山地剥', '火地晋', '火天大有'], + 兑: ['兑为泽', '泽水困', '泽地萃', '泽山咸', '水山蹇', '地山谦', '雷山小过', '雷泽归妹'], + 离: ['离为火', '火山旅', '火风鼎', '火水未济', '山水蒙', '风水涣', '天水讼', '天火同人'], + 震: ['震为雷', '雷地豫', '雷水解', '雷风恒', '地风升', '水风井', '泽风大过', '泽雷随'], + 巽: ['巽为风', '风天小畜', '风火家人', '风雷益', '天雷无妄', '火雷噬嗑', '山雷颐', '山风蛊'], + 坎: ['坎为水', '水泽节', '水雷屯', '水火既济', '泽火革', '雷火丰', '地火明夷', '地水师'], + 艮: ['艮为山', '山火贲', '山天大畜', '山泽损', '火泽睽', '天泽履', '风泽中孚', '风山渐'], + 坤: ['坤为地', '地雷复', '地泽临', '地天泰', '雷天大壮', '泽天夬', '水天需', '水地比'], + }; + const stageNames = ['本宫', '一世', '二世', '三世', '四世', '五世', '游魂', '归魂']; + const byPalace = new Map(); + for (const hexagram of HEXAGRAM_LIST) { + const { palace, stage } = palaceOf(hexagram); + if (!byPalace.has(palace.name)) byPalace.set(palace.name, new Map()); + byPalace.get(palace.name).set(stage.name, hexagram.name); + } + assert.equal(byPalace.size, 8, '不是八宫'); + for (const [trigram, names] of Object.entries(expected)) { + const table = byPalace.get(`${trigram}宫`); + assert.ok(table, `缺 ${trigram}宫`); + for (let index = 0; index < names.length; index += 1) { + assert.equal(table.get(stageNames[index]), names[index], `${trigram}宫${stageNames[index]}推出岔了`); + } + } + // 六十四卦各归一宫,不重不漏 + assert.equal(new Set(HEXAGRAM_LIST.map((h) => jingfang(h).palaceName + jingfang(h).stage)).size, 64); +}); + +test('归魂是只变第五爻,不是变第四、五爻', async () => { + // 这一句最容易记错:游魂卦把下三爻收回来,净效果只剩第五爻被变。 + // 写成变第四、五爻的话,八宫的归魂卦整列会变成别宫的二世卦。 + const { palaceOf } = await import('../miniapp/node/jingfang.mjs'); + const 乾宫 = HEXAGRAM_LIST.filter((h) => palaceOf(h).palace.name === '乾宫'); + const gui = 乾宫.find((h) => palaceOf(h).stage.name === '归魂'); + assert.equal(gui.name, '火天大有'); + // 火天大有 = 111101,本宫乾为天 = 111111,只有第五爻不同 + const base = HEXAGRAM_LIST.find((h) => h.name === '乾为天').key; + const differ = (key) => [...key].flatMap((bit, index) => (bit === base[index] ? [] : [index + 1])); + assert.deepEqual(differ(gui.key), [5], '归魂相对本宫被变的爻位不对'); + // 游魂(晋)则要变初、二、三、五爻 + const you = 乾宫.find((h) => palaceOf(h).stage.name === '游魂'); + assert.deepEqual(differ(you.key), [1, 2, 3, 5], '游魂相对本宫被变的爻位不对'); +}); + +test('同宫八卦按世次排成一列,卦名与次序跟传世卦序逐宫对得上', async () => { + // 卦盘上那一列小卦就是这一列。BY_HEXAGRAM_KEY 是按卦反查宫的表,没排过序; + // 这一列有次序,次序本身就是「一世到五世、游魂归魂」——排错或抄错,看图的人就照错的读。 + const { jingfang, palaceRoster } = await import('../miniapp/node/jingfang.mjs'); + const expected = { + 乾: ['乾为天', '天风姤', '天山遁', '天地否', '风地观', '山地剥', '火地晋', '火天大有'], + 兑: ['兑为泽', '泽水困', '泽地萃', '泽山咸', '水山蹇', '地山谦', '雷山小过', '雷泽归妹'], + 离: ['离为火', '火山旅', '火风鼎', '火水未济', '山水蒙', '风水涣', '天水讼', '天火同人'], + 震: ['震为雷', '雷地豫', '雷水解', '雷风恒', '地风升', '水风井', '泽风大过', '泽雷随'], + 巽: ['巽为风', '风天小畜', '风火家人', '风雷益', '天雷无妄', '火雷噬嗑', '山雷颐', '山风蛊'], + 坎: ['坎为水', '水泽节', '水雷屯', '水火既济', '泽火革', '雷火丰', '地火明夷', '地水师'], + 艮: ['艮为山', '山火贲', '山天大畜', '山泽损', '火泽睽', '天泽履', '风泽中孚', '风山渐'], + 坤: ['坤为地', '地雷复', '地泽临', '地天泰', '雷天大壮', '泽天夬', '水天需', '水地比'], + }; + const stageNames = ['本宫', '一世', '二世', '三世', '四世', '五世', '游魂', '归魂']; + for (const [trigram, names] of Object.entries(expected)) { + const base = HEXAGRAM_LIST.find((h) => h.name === names[0]); + const roster = palaceRoster(base); + assert.equal(roster.length, 8, `${trigram}宫不是八格`); + assert.deepEqual(roster.map((s) => s.stage), stageNames, `${trigram}宫的世次次序不对`); + assert.deepEqual(roster.map((s) => s.name), names, `${trigram}宫这一列跟传世卦序对不上`); + // 一格里卦名与卦象必须指同一个卦,别拿名字配错卦象 + for (const slot of roster) { + assert.equal(slot.key, hexagramByOrder(HEXAGRAM_LIST.find((h) => h.name === slot.name).order).key, + `${trigram}宫${slot.stage}那格的卦名与卦象不是一卦`); + } + } +}); + +test('游魂那格不含第四爻、归魂只变第五爻,「游」「归」不是随口起的', async () => { + // 递进到五世之后忽然要往回退,这一退就是这两个名字的全部由来。 + // 名次图注上写着这句话,所以它得是真的:变爻错一爻,「游」「归」两个字就空了。 + const { jingfang, palaceRoster } = await import('../miniapp/node/jingfang.mjs'); + for (const palace of ['乾宫', '兑宫', '离宫', '震宫', '巽宫', '坎宫', '艮宫', '坤宫']) { + const base = HEXAGRAM_LIST.find((h) => { + const j = jingfang(h); + return j.palaceName === palace && j.stage === '本宫'; + }); + const [ben, yi, er, san, si, wu, you, gui] = palaceRoster(base); + // 一世到五世:世次一级一级往上爬,变过的爻一级一级往上加 + assert.deepEqual([ben, yi, er, san, si, wu].map((s) => s.shi), [6, 1, 2, 3, 4, 5], + `${palace}世次没有逐爻上移`); + assert.deepEqual([yi, er, san, si, wu].map((s) => s.flips.length), [1, 2, 3, 4, 5], + `${palace}一到五世的变爻数不对`); + // 游魂:外卦复本,第四爻退回去了,所以变爻里没有第四爻;世爻跟着退到四爻 + assert.equal(you.stage, '游魂', `${palace}第七格不是游魂`); + assert.ok(!you.flips.includes(4), `${palace}游魂的变爻里混进了第四爻`); + assert.deepEqual(you.flips, [1, 2, 3, 5], `${palace}游魂的变爻不对`); + assert.equal(you.shi, 4, `${palace}游魂的世爻没退到四爻`); + // 归魂:下三爻收回来,净效果只剩第五爻被变;世爻退到三爻 + assert.equal(gui.stage, '归魂', `${palace}第八格不是归魂`); + assert.deepEqual(gui.flips, [5], `${palace}归魂不是只变第五爻`); + assert.equal(gui.shi, 3, `${palace}归魂的世爻没退到三爻`); + // 世应相隔三位,这是六爻通例 + for (const slot of palaceRoster(base)) { + assert.equal(slot.ying, slot.shi > 3 ? slot.shi - 3 : slot.shi + 3, + `${palace}${slot.stage}的世应相隔不是三位`); + } + } +}); + +test('六十四卦在同宫名单里都找得到自己那一格,两处世应一致', async () => { + const { jingfang, palaceRoster } = await import('../miniapp/node/jingfang.mjs'); + for (const h of HEXAGRAM_LIST) { + const j = jingfang(h); + assert.equal(j.roster, palaceRoster(h), `${h.name}:两条取法拿到的不是同一份名单`); + const slot = j.roster.find((s) => s.key === h.key); + assert.ok(slot, `${h.name}在本宫名单里找不到自己`); + assert.equal(slot.stage, j.stage, `${h.name}那一格的世次对不上`); + assert.equal(slot.shi, j.shi, `${h.name}那一格的世爻对不上`); + assert.equal(slot.ying, j.ying, `${h.name}那一格的应爻对不上`); + } + // 游魂归魂每宫各一,十六卦。这一格在本卦上会同时挂「就是你」与「这是例外级」两个标记, + // 客户端那条朱砂压过加粗的规则就是为它们准备的,所以数目得钉住。 + const odd = HEXAGRAM_LIST.filter((h) => ['游魂', '归魂'].includes(jingfang(h).stage)); + assert.equal(odd.length, 16, '游魂归魂不是十六卦'); +}); + +test('同宫名单是冻结的:它被这一宫所有卦共用,谁都不能就地改坏', async () => { + // 同一宫的八个卦读出来的是同一份数组。不冻的话,看过一次八宫图改了它, + // 后面这一宫别的卦读到的就是被改过的——而且从哪看出来的都看不出来。 + const { jingfang, palaceRoster } = await import('../miniapp/node/jingfang.mjs'); + const base = HEXAGRAM_LIST.find((h) => { + const j = jingfang(h); + return j.palaceName === '离宫' && j.stage === '本宫'; + }); + const roster = palaceRoster(base); + assert.ok(Object.isFrozen(roster), '名单本身没冻'); + assert.ok(Object.isFrozen(roster[0]), '单格没冻'); + assert.ok(Object.isFrozen(roster[0].flips), '变爻数组没冻'); + assert.throws(() => { 'use strict'; roster[0].name = '别的卦'; }, TypeError, '名单竟然能改'); + assert.equal(palaceRoster(base)[0].name, '离为火', '改坏了还在往外发'); +}); + +test('纳支照纳支歌诀,八纯卦内外首支逐条对上', async () => { + const { jingfang } = await import('../miniapp/node/jingfang.mjs'); + // 「乾金甲子外壬午,坎水戊寅外戊申,艮土丙辰外丙戌,震木庚子外庚午, + // 巽木辛丑外辛未,离火己卯外己酉,坤土乙未外癸丑,兑金丁巳外丁亥」 + const song = { + 乾为天: ['甲子', '壬午'], 坎为水: ['戊寅', '戊申'], 艮为山: ['丙辰', '丙戌'], + 震为雷: ['庚子', '庚午'], 巽为风: ['辛丑', '辛未'], 离为火: ['己卯', '己酉'], + 坤为地: ['乙未', '癸丑'], 兑为泽: ['丁巳', '丁亥'], + }; + for (const [name, [inner, outer]] of Object.entries(song)) { + const jf = jingfang(HEXAGRAM_LIST.find((h) => h.name === name)); + assert.equal(jf.lines[0].stem + jf.lines[0].branch, inner, `${name} 内卦首支不符歌诀`); + assert.equal(jf.lines[3].stem + jf.lines[3].branch, outer, `${name} 外卦首支不符歌诀`); + } +}); + +test('纳支随经卦阴阳,不随卦宫阴阳', async () => { + // 山水蒙属离宫(阴宫),但下艮上坎都是阳卦,故六爻全纳阳支。写成按宫分阴阳就错了。 + const { jingfang } = await import('../miniapp/node/jingfang.mjs'); + const { TRIGRAMS } = await import('../miniapp/node/hexagrams.mjs'); + const yangBranch = new Set(['子', '寅', '辰', '午', '申', '戌']); + for (const hexagram of HEXAGRAM_LIST) { + const jf = jingfang(hexagram); + for (const line of jf.lines) { + const trigram = line.position <= 3 + ? TRIGRAMS[hexagram.lower].name + : TRIGRAMS[hexagram.upper].name; + const isYangTrigram = ['乾', '震', '坎', '艮'].includes(trigram); + assert.equal( + yangBranch.has(line.branch), + isYangTrigram, + `${hexagram.name}${line.label} 纳${line.branch},与${trigram}卦阴阳不符`, + ); + } + } +}); + +test('六亲以本宫五行为我,配法合于五行生克', async () => { + const { jingfang } = await import('../miniapp/node/jingfang.mjs'); + // 乾宫属金:土生金故父母,金生水故子孙,火克金故官鬼,金克木故妻财,同金故兄弟 + const qian = jingfang(HEXAGRAM_LIST.find((h) => h.name === '乾为天')); + assert.deepEqual(qian.lines.map((l) => l.relative), ['子孙', '妻财', '父母', '官鬼', '兄弟', '父母']); + assert.deepEqual(qian.lines.map((l) => l.element), ['水', '木', '土', '火', '金', '土']); + // 坤宫属土:火生土故父母,土生金故子孙,木克土故官鬼,土克水故妻财,同土故兄弟 + const kun = jingfang(HEXAGRAM_LIST.find((h) => h.name === '坤为地')); + assert.deepEqual(kun.lines.map((l) => l.relative), ['兄弟', '父母', '官鬼', '兄弟', '妻财', '子孙']); + // 坎宫属水:土克水故官鬼,水生木故子孙,水克火故妻财,金生水故父母,同水故兄弟 + const kan = jingfang(HEXAGRAM_LIST.find((h) => h.name === '坎为水')); + assert.deepEqual(kan.lines.map((l) => l.relative), ['子孙', '官鬼', '妻财', '父母', '官鬼', '兄弟']); +}); + +test('世爻由宫与世次定,应爻隔三位且不越界', async () => { + const { jingfang } = await import('../miniapp/node/jingfang.mjs'); + // 一世初、二世二、三世三、四世四、五世五、本宫上爻、游魂四、归魂三 + const shiOf = { 本宫: 6, 一世: 1, 二世: 2, 三世: 3, 四世: 4, 五世: 5, 游魂: 4, 归魂: 3 }; + const seen = new Set(); + for (const hexagram of HEXAGRAM_LIST) { + const jf = jingfang(hexagram); + assert.equal(jf.shi, shiOf[jf.stage], `${hexagram.name} 是${jf.stage}卦,世爻位不对`); + // 初应四、二应五、三应六,返过来四应一、五应二、六应三。 + // 照字面「世 + 3」的话,本宫、六爻与五世卦会算到第八、九爻去。 + assert.equal(jf.ying, jf.shi <= 3 ? jf.shi + 3 : jf.shi - 3, `${hexagram.name} 应爻隔位不对`); + assert.ok(jf.ying >= 1 && jf.ying <= 6, `${hexagram.name} 应爻越界:${jf.ying}`); + assert.equal(jf.lines.filter((l) => l.role).length, 2, `${hexagram.name} 世应标记数不对`); + assert.ok(jf.lines[jf.shi - 1].role === '世' && jf.lines[jf.ying - 1].role === '应'); + seen.add(`${jf.shi}-${jf.ying}`); + } + assert.equal(seen.size, 6, '世应配对应有六种'); +}); + +test('断语给出六亲世应,且不与梅花的主客混说世应', () => { + const reading = buildReading(castByNumbers(3, 8), { now: new Date(2026, 8, 29), question: '要不要接这个 offer' }); + const segment = reading.insights.find((item) => item.title === '六亲世应'); + assert.ok(segment, '断语里没有「六亲世应」'); + const jf = reading.jingfang; + const shi = jf.lines[jf.shi - 1]; + const ying = jf.lines[jf.ying - 1]; + assert.ok(segment.text.includes(jf.palaceName + jf.stage), '没点出宫与世次'); + assert.ok(segment.text.includes(`世爻${shi.label}持${shi.relative}`), '没点出世爻身份'); + assert.ok(segment.text.includes(`应爻${ying.label}为${ying.relative}`), '没点出应爻身份'); + // 动爻的六亲要说清「事落在谁身上」 + const moving = reading.movingLines.map((line) => jf.lines[line.position - 1].relative); + for (const relative of moving) assert.ok(segment.text.includes(relative), `没点到动爻六亲 ${relative}`); + + // 梅花那一层只能说主客,不能再自称世应——同一段里两个世爻位会看糊涂 + const host = reading.insights.find((item) => item.title === '主客'); + assert.ok(host, '断语里没有「主客」'); + assert.ok(!reading.insights.some((item) => item.title === '世应'), '「世应」这一段仍被梅花占用'); + // 对应爻是初应四、二应五、三应上,来回都跨内外两卦:主客恒分居两卦, + // 写成「同在下卦」是跟 counterpart 的定义打架。 + const a = reading.structure.shi.position; + const b = reading.structure.ying.position; + assert.ok((a <= 3) !== (b <= 3), '主客两爻本该分居内外两卦'); + assert.ok(!/同在下卦|同在上卦/.test(host.text), '主客段仍断言两爻同处一卦'); + assert.ok(/恒分居内卦与外卦/.test(host.text), '主客段没有点明主客恒分居两卦'); + // 变卦另有一套宫与世次,不能沿用本卦 + if (reading.changed) { + assert.ok(reading.changedJingfang, '变卦没有装京房'); + assert.equal(reading.changedJingfang.palaceName.length + reading.changedJingfang.stage.length > 0, true); + } +}); + +test('卦体把六亲与世应画出来,不只是数据里有', async () => { + // 钉在调用点,不钉在函数存在——只验 guaLines 里有 .rel,解读页照样可以不调它。 + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + // 掐出 guaLines 自己的函数体:锚到下一个 function,否则 indexOf 取到的区间是空的 + const start = client.indexOf('function guaLines('); + const body = client.slice(start, client.indexOf('\n function ', start + 10)); + assert.ok(body.length > 0, '没找到 guaLines 函数体'); + assert.ok(/\.rel/.test(body) && /na\.relative/.test(body), '卦体没画六亲'); + assert.ok(/na\.role/.test(body), '卦体没标世应'); + assert.ok(/jingfang\.lines\[position - 1\]/.test(body), '卦体没按爻位取纳甲'); + assert.ok(/isGod/.test(body) && /role god/.test(body), '卦体没标用神'); + assert.ok(/const fu = \(hidden \|\| \[\]\)/.test(body), '卦体没接伏神'); + assert.ok(/fu\.hushen/.test(body), '卦体没画出伏神那一爻'); + // 本卦与变卦都要传进去,且是从 reading 上取的 + const render = client.slice(client.indexOf('left.append(guaBlock')); + assert.ok( + /guaBlock\([\s\S]{0,200}?reading\.jingfang,[\s\S]{0,200}?reading\.useGod && reading\.useGod\.picked/.test(render), + '解读页本卦没把京房数据与用神传进卦体', + ); + assert.ok(/reading\.useGod\.hidden/.test(render), '解读页本卦没把伏神传进卦体'); + // 变卦要把化爻传进去,才标得出哪一格是由本卦动爻变过来的 + assert.ok( + /guaBlock\(reading\.changed, null, '变卦', reading\.changedJingfang,[\s\S]{0,200}?reading\.transforms \|\| \[\][\s\S]{0,200}?\)\)/.test(render), + '解读页变卦没传京房数据与化爻', + ); + // 六冲/六合那枚小标,本卦与变卦都要传——变卦也可能是六冲卦,那正是「六合变六冲」 + // 要在卦面上看得见的地方 + assert.ok( + /reading\.useGod && reading\.useGod\.circle\) \|\| null,\s*clashTag\(reading, '本卦'\)/.test(render), + '解读页本卦没把卦体冲合小标传进卦体', + ); + assert.ok(/clashTag\(reading, '变卦'\)/.test(render), '解读页变卦没把卦体冲合小标传进卦体'); + // 右栏摘要也得有这一行 + assert.ok(/\['六亲世应',/.test(client), '右栏没有六亲世应摘要'); + assert.ok(/\['用神',/.test(client), '右栏没有用神摘要'); + assert.ok(/\['主客',/.test(client), '右栏仍把体用那层叫世应'); + // 卦库详情页也要装上,同一根代码两个地方都传 + assert.ok(/guaLines\(item, null, item\.palace \|\| null\)/.test(client), '卦库详情页没把京房数据传进卦体'); + assert.ok(/item\.palace\.palaceName \+ item\.palace\.stage/.test(client), '卦库详情页标题没带宫位'); +}); + +test('查卦给宫位与世应,full 档再给六亲全表', async () => { + const { handleMcpRequest } = await import('../miniapp/node/mcp/divination-http.mjs'); + const ask = async (args) => { + let raw = ''; + const response = { writeHead() { return this; }, end(chunk) { raw += chunk; return this; } }; + await handleMcpRequest({ + response, + body: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'divination_hexagram_lookup', arguments: args } }, + }); + return JSON.parse(raw).result; + }; + const brief = await ask({ query: '谦' }); + const full = await ask({ query: '谦', detail: 'full' }); + const briefText = brief.content[0].text; + const fullText = full.content[0].text; + // 谦为兑宫五世卦:内艮丙辰土父母、丙午火官鬼、丙申金兄弟;外坤癸丑土父母、癸亥水子孙持世、癸酉金兄弟 + assert.ok(/兑宫五世卦/.test(briefText), 'brief 档没给宫位与世次'); + assert.ok(/世五爻持子孙/.test(briefText), 'brief 档没给世爻身份'); + assert.ok(/应二爻为官鬼/.test(briefText), 'brief 档没给应爻身份'); + assert.ok(!/^六亲:/m.test(briefText), 'brief 档不该塞六亲全表'); + assert.ok(/^六亲:/m.test(fullText), 'full 档缺六亲全表'); + assert.ok(/丙辰土父母/.test(fullText), 'full 档六亲没带干支'); + assert.ok(briefText.length < fullText.length, 'full 档没有比 brief 档长'); +}); + +// ── 用神 ───────────────────────────────────────────────────────────────── + +test('问何事取何亲为用神,取法有传世出处', async () => { + const { TOPIC_CLASSES, detectTopic } = await import('../miniapp/node/topics.mjs'); + // 逐条核对取法,不靠印象:问财取妻财、求职取官鬼、文书取父母、医药取子孙、 + // 官司取官鬼、失物取妻财;婚恋分男女;占病是官鬼为病症、子孙为医药两头看。 + const expected = { + 财运: ['妻财'], + 事业功名: ['官鬼'], + 感情: ['妻财', '官鬼'], + 婚恋: ['妻财', '官鬼'], + 疾病: ['官鬼', '子孙'], + 学业文书: ['父母'], + 房产车契: ['父母'], + 官讼是非: ['官鬼'], + 出行寻物: ['妻财'], + }; + for (const topic of TOPIC_CLASSES) { + assert.deepEqual(topic.god.relatives, expected[topic.label], `${topic.label} 的用神取法不对`); + assert.ok(topic.god.reason && topic.god.reason.length > 8, `${topic.label} 的取法没有给出处`); + } + // 分男女的必须写明依据,也必须写明不替人认性别 + const marriage = TOPIC_CLASSES.find((t) => t.label === '婚恋'); + assert.ok(marriage.god.byGender, '婚恋没标分男女'); + assert.ok(/增删卜易/.test(marriage.god.reason), '婚恋的取法没有引《增删卜易》'); + // 关键词仍走原来的匹配 + assert.equal(detectTopic('这单生意能赚钱吗').label, '财运'); + assert.equal(detectTopic('明天面试能过吗').label, '事业功名'); + assert.equal(detectTopic('这病能好么').label, '疾病'); +}); + +test('用神择爻:动爻优先,其次近世', async () => { + const { pickUseGod } = await import('../miniapp/node/jingfang.mjs'); + const { jingfang } = await import('../miniapp/node/jingfang.mjs'); + const { hexagramByOrder } = await import('../miniapp/node/hexagrams.mjs'); + const h = jingfang(hexagramByOrder(1)); // 乾为天:子孙妻财父母官鬼兄弟父母,世6 + // 卦中独一 + const single = pickUseGod(h, ['妻财'], []); + assert.equal(single.all.length, 1); + assert.equal(single.picked.position, 2); + assert.equal(single.why, '卦中独一'); + + // 两现:都不动时取近世爻者。乾为天父母在三、六爻,而世爻正是六爻—— + // 六爻距离为零,三爻差三位,所以取六爻。 + const both = pickUseGod(h, ['父母'], []); + assert.equal(both.all.length, 2); + assert.equal(both.why, '近世爻者'); + assert.equal(both.picked.position, 6, '父爻在 3、6 爻,世 6,应取距离为零的 6 爻'); + + // 两现:动爻优先,哪怕动的那个不是近世爻 + const animated = pickUseGod(h, ['父母'], [3]); + assert.equal(animated.why, '动爻优先'); + assert.equal(animated.picked.position, 3, '有动爻就该取动的那个,不管远近'); + + // 候选不止一亲时不择:择了就等于替求测者认了性别或认了病势 + const twoGods = pickUseGod(h, ['妻财', '官鬼'], []); + assert.equal(twoGods.picked, null, '两亲并列时不该硬择用神'); + assert.equal(twoGods.why, '两亲各看各的'); + assert.deepEqual(twoGods.present, ['妻财', '官鬼']); + + // 不上卦 + const missing = pickUseGod(h, ['子子孙孙'], []); + assert.equal(missing.picked, null); + assert.equal(missing.why, '不上卦'); + assert.deepEqual(missing.absent, ['子子孙孙']); +}); + +test('断语给出用神,且分男女与不上卦都不硬编', () => { + // 财运:一亲,取得到,说清取哪一爻、与世爻什么关系 + const wealth = buildReading(castByNumbers(3, 8), { now: new Date(2026, 8, 29), question: '这单生意能赚钱吗' }); + const wg = wealth.insights.find((item) => item.title === '用神'); + assert.ok(wg, '断语里没有「用神」'); + assert.ok(/所问为财运/.test(wg.text), '用神段没点出事类'); + assert.ok(/求财取妻财/.test(wg.text), '用神段没给出取法依据'); + assert.ok(wealth.useGod && wealth.useGod.picked, '财运应有取到的用神'); + assert.deepEqual(wealth.useGod.relatives, ['妻财']); + const godLine = wealth.useGod.picked; + assert.ok(wg.text.includes(godLine.label), '用神段没点出所取的那一爻'); + assert.ok(/用神与世爻同气|用神恰在世爻之上|世爻生用神|用神生世爻|世爻克用神|用神克世爻/.test(wg.text), '用神段没说用神与世爻的关系'); + + // 婚恋分男女:只各报所在,不替人择——择了就等于替求测者认了性别 + const love = buildReading(castByNumbers(3, 8), { now: new Date(2026, 8, 29), question: '跟他会不会复合' }); + assert.equal(love.useGod.picked, null, '婚恋不该硬择用神'); + const lg = love.insights.find((item) => item.title === '用神'); + assert.ok(/对照自己那一亲取用/.test(lg.text), '婚恋没把取舍交回求测者'); + assert.ok(/增删卜易/.test(lg.text), '婚恋用神段没引出处'); + // 两亲都在时要把所在都列出来 + for (const name of love.useGod.present) { + const label = love.jingfang.lines.filter((l) => l.relative === name).map((l) => l.label); + for (const item of label) assert.ok(lg.text.includes(item), `婚恋用神段没列出${name}在${item}`); + } + + // 没写问题就明说取不出,不硬套 + const bare = buildReading(castByNumbers(3, 8), { now: new Date(2026, 8, 29) }); + assert.equal(bare.useGod, null); + assert.match(bare.insights.find((item) => item.title === '用神').text, /未写所问何事/); + + // 写了问题、但表里没这一类,是另一回事,不能也说成「未写所问何事」—— + // 问卦的人明明写了字,把话没说到位赖到他头上,是把缺的那一层说错了。 + const offTable = buildReading(castByNumbers(3, 8), { now: new Date(2026, 8, 29), question: '这次合作能不能谈成' }); + assert.equal(offTable.useGod, null, '「合作」本就不在任何一类里'); + const offText = offTable.insights.find((item) => item.title === '用神').text; + assert.ok(!/未写所问何事/.test(offText), '写了问题却说人家没写'); + assert.ok(/认得的事类词/.test(offText), '没说是「表里没接住」这一层'); + // 九类要列全,且是从表里取的,不是写死一份 + for (const item of TOPIC_CLASSES) { + assert.ok(offText.includes(item.label), `认不出事类时没列出${item.label}`); + } +}); + +test('用神不上卦时不编,如实说是缺哪一层', () => { + // 疾病取官鬼与子孙两头;若卦中子孙不上卦,要写明是哪一亲不上, + // 而不是随便挑一亲当用神。 + const reading = buildReading(castByNumbers(3, 8), { now: new Date(2026, 8, 29), question: '这病能好么' }); + assert.deepEqual(reading.useGod.relatives, ['官鬼', '子孙']); + const text = reading.insights.find((item) => item.title === '用神').text; + for (const name of reading.useGod.absent) { + assert.ok(text.includes(name), `没说清${name}不上卦`); + assert.ok(text.includes('不上卦'), '没写「不上卦」三个字'); + } + assert.ok(!/取.{0,4}爻。/.test(text) || reading.useGod.picked === null, '不上卦时不该宣称取了哪一爻'); +}); + +// ── 伏神 ───────────────────────────────────────────────────────────────── + +test('伏神取自本宫首卦同爻位,书上两个例证逐字对上', async () => { + const { hiddenGod, flyingRelation, jingfang } = await import('../miniapp/node/jingfang.mjs'); + const byName = (name) => jingfang(HEXAGRAM_LIST.find((h) => h.name === name)); + + // 《增删卜易·飞伏神章第二十八》:「乾卦寅木妻财在二爻,即以此寅木伏于姤卦亥水之下, + // 姤卦二爻之亥水即为飞神,寅木妻财即为伏神,亥水而生寅木,谓之飞来生伏得长生。」 + const gou = hiddenGod(byName('天风姤'), '妻财'); + assert.equal(gou.hushen.stem + gou.hushen.branch, '甲寅', '姤卦的妻财伏神应是甲寅木'); + assert.equal(gou.hushen.position, 2, '妻财伏神应伏在二爻'); + assert.equal(gou.feishen.stem + gou.feishen.branch, '辛亥', '压着它的飞神应是姤卦二爻辛亥水'); + assert.equal(flyingRelation(gou.hushen, gou.feishen).key, '飞来生伏'); + + // 同章第二个例:「乾卦子水子孙在初爻,即以此子水子孙伏于遁卦辰土之下……辰土而克子水, + // 谓之飞来克伏遭克害,名为伏神受制,有用亦无用矣,即以凶推。」 + // 兑宫的例:泽山咸(兑宫三世)缺妻财,须从兑为天借丁卯木伏二爻, + // 压着它的是咸卦二爻丙午火官鬼——伏去生飞,泄气。 + const xian = hiddenGod(byName('泽山咸'), '妻财'); + assert.equal(xian.palaceName, '兑宫', '咸卦的伏神应从兑宫借'); + assert.equal(xian.hushen.stem + xian.hushen.branch, '丁卯', '咸卦的妻财伏神应是丁卯木'); + assert.equal(xian.hushen.position, 2); + assert.equal(xian.feishen.stem + xian.feishen.branch, '丙午'); + assert.equal(flyingRelation(xian.hushen, xian.feishen).key, '伏去生飞'); + + const dun = hiddenGod(byName('天山遁'), '子孙'); + assert.equal(dun.hushen.stem + dun.hushen.branch, '甲子', '遁卦的子孙伏神应是甲子水'); + assert.equal(dun.hushen.position, 1, '子孙伏神应伏在初爻'); + assert.equal(flyingRelation(dun.hushen, dun.feishen).key, '飞来克伏'); + // 书上说辰土克子水作凶推,本包不硬套吉凶,但伏飞方向不能反 + assert.ok(dun.feishen.element === '土' && dun.hushen.element === '水', '飞伏五行与书不合'); +}); + +test('六十四卦全量:缺失的六亲都能取到唯一伏神', async () => { + const { hiddenGod, jingfang } = await import('../miniapp/node/jingfang.mjs'); + let missing = 0; + let found = 0; + let ambiguous = 0; + for (const hexagram of HEXAGRAM_LIST) { + const jf = jingfang(hexagram); + for (const relative of ['父母', '兄弟', '子孙', '妻财', '官鬼']) { + if (jf.lines.some((line) => line.relative === relative)) continue; + missing += 1; + const pair = hiddenGod(jf, relative); + assert.ok(pair, `${hexagram.name} 缺${relative}却取不到伏神`); + found += 1; + // 书上两个例证都在乾宫,兑宫坎宫的例证一个也没有——只拿那两例去验, + // 「去别宫借伏神」这种改法照样全绿。宫名必须逐卦对上。 + assert.equal(pair.palaceName, jf.palaceName, `${hexagram.name} 的伏神不是从本宫借的`); + // 伏神所在爻位必须是本宫首卦里那一亲的位置,且飞神就是本卦同爻位那一爻 + assert.equal(pair.feishen, jf.lines[pair.hushen.position - 1], `${hexagram.name} 的飞神没对上同爻位`); + assert.equal(pair.hushen.relative, relative); + if (pair.ambiguous) ambiguous += 1; + } + } + // 八纯卦六亲俱全,缺失只发生在本宫的其他七卦上;缺失数应与实际相符且不出现歧义 + assert.equal(missing, found); + assert.equal(ambiguous, 0, '出现了同亲两爻同时缺失的歧义,本包的取法未处理这种情况'); + assert.ok(missing > 0, '全量没有一例缺失,测试等于没跑'); +}); + +test('断语遇不上卦时取伏神,并说清飞伏与出不出得来', () => { + // 找一卦使财运的妻财不上卦 + let reading = null; + for (const [upper, lower] of [[5, 2], [2, 7], [4, 3], [6, 1]]) { + const candidate = buildReading(castByNumbers(upper, lower), { now: new Date(2026, 8, 29), question: '这单生意能赚钱吗' }); + if (candidate.useGod && candidate.useGod.picked === null) { reading = candidate; break; } + } + assert.ok(reading, '没找到妻财不上卦的一卦'); + const text = reading.insights.find((item) => item.title === '用神').text; + assert.ok(/不上卦/.test(text), '没点明用神不上卦'); + assert.ok(/增删卜易/.test(text), '没引《增删卜易》飞伏神章'); + assert.ok(/伏在\d爻之下/.test(text), '没说伏神伏在哪一爻'); + assert.ok(/飞神/.test(text), '没点出飞神'); + assert.match(text, /飞来生伏|伏去生飞|伏来克飞|飞来克伏|飞伏同气/, '没给飞伏生克的定名'); + + // 出不出得来:要么给结论并说凭哪条,要么明说缺哪一层,不许凭空断 + assert.match(text, /出得来|终不得出|无从判/, '出伏一句都没有'); + // 断语里提到的月建日辰条件是本包算得出的,出不来时必须交代还缺什么 + if (/无从判/.test(text)) { + assert.ok(/旬空|月破/.test(text), '无从判时必须说明缺哪几项判据'); + } + + // 结构化字段也要带出来 + assert.ok(Array.isArray(reading.useGod.hidden) && reading.useGod.hidden.length > 0, 'reading 没带伏神'); + const fu = reading.useGod.hidden[0]; + assert.equal(fu.relative, '妻财'); + assert.ok(/^[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥][木火土金水]$/.test(fu.hushen), '伏神干支格式不对'); + assert.ok(fu.flying, '没给飞伏关系名'); + // 出伏结论得是结构化字段,不只是正文里的一句话:Agent 复述时要的是「出不出得来」本身。 + assert.equal(fu.emerges && fu.emerges.key, '出得来', '伏神没给出伏结论'); + assert.ok(text.includes(fu.emerges.text), '正文说的出伏结论与结构化字段对不上'); + // 正文那段话是拼出来的,不该再塞进结构化字段里撑大响应 + assert.equal(fu.sentence, undefined, 'sentence 只该进断语,不该留在 reading 里'); +}); + +test('MCP 起卦把伏神一并带进 structuredContent', async () => { + // 断语正文里已经讲过一遍,Agent 复述「伏在哪、飞神是谁、出不出得来」时 + // 不该再从一段话里去刨——这三个答案得是能直接取的字段。 + const { handleMcpRequest } = await import('../miniapp/node/mcp/divination-http.mjs'); + let raw = ''; + const response = { writeHead() { return this; }, end(chunk) { raw += chunk; return this; } }; + await handleMcpRequest({ + response, + body: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'divination_cast', + arguments: { method: 'numbers', upper: 5, lower: 2, question: '这单生意能赚钱吗' }, + }, + }, + }); + const out = JSON.parse(raw).result; + const useGod = out.structuredContent.useGod; + assert.ok(useGod, 'structuredContent 里没有用神'); + assert.equal(useGod.picked, null, '这一卦的妻财本该不上卦'); + assert.ok(Array.isArray(useGod.hidden) && useGod.hidden.length === 1, 'MCP 没带伏神'); + const fu = useGod.hidden[0]; + assert.equal(fu.relative, '妻财'); + assert.equal(fu.position, 5); + assert.equal(fu.hushen, '丙子水'); + assert.equal(fu.feishen, '辛巳火'); + assert.equal(fu.flying, '伏来克飞'); + assert.equal(fu.emerges.key, '出得来'); + // 断语正文说过的同一件事,两处必须对得上 + assert.ok(out.content[0].text.includes(fu.emerges.text), '正文与 structuredContent 的出伏结论不一致'); +}); + +test('出伏七条逐条落地,旬空月破入墓都算数了', async () => { + // 《增删卜易·飞伏神章》列「易出六」与「不出五」。上一轮只做得出四条, + // 剩下一条「飞神逢空破墓绝」和两条「正逢墓绝」「直旬空月破」当时说没做。 + // 这一轮把旬空、月破、入墓都补上,那句「本包未做」必须随之消失。 + const source = await readFile(new URL('../miniapp/node/divination.mjs', import.meta.url), 'utf8'); + assert.ok(!/本包未做/.test(source), '出伏条件已补齐,还留着「本包未做」是过期话'); + assert.ok(!/HIDDEN_GAP/.test(source), '出伏缺口的常量该删了'); + assert.ok(/得月建生/.test(source) && /得日辰生/.test(source) && /得飞神生/.test(source), '原有的出伏条件掉了'); + assert.ok(/占卦之日月于伏神正逢入墓/.test(source), '没接上「正逢墓绝」这一条'); + assert.ok(/伏神逢月破/.test(source), '没接上「直逢旬空月破」这一条'); + assert.ok(/飞神逢空破墓/.test(source), '没接上「飞神逢空破墓压不住它」这一条'); + + // 上面是读源码。下面真跑:造一个伏神正逢旬空的日子,看「终不得出」这一路走不走得到。 + const jfModule = await import('../miniapp/node/jingfang.mjs'); + const almanac = await import('../miniapp/node/almanac.mjs'); + const pair = jfModule.hiddenGod(jfModule.jingfang(HEXAGRAM_LIST.find((h) => h.name === '泽山咸')), '妻财'); + assert.ok(pair, '取不到兑宫咸卦的妻财伏神'); + assert.equal(pair.hushen.element, '木'); + assert.equal(pair.feishen.element, '火', '咸卦二爻应是丙午火官鬼'); + // 卯木妻财伏在二爻;找出让卯落进旬空的日子 + let hit = null; + for (let d = 0; d < 60; d += 1) { + if (almanac.xunKong(d).voidBranches.includes(pair.hushen.branchIndex)) { hit = d; break; } + } + assert.ok(hit !== null, '六十日里总该有几天卯是旬空'); + const empty = jfModule.voidReading(pair.hushen, { + monthBranch: 0, dayBranch: almanac.xunKong(hit).voidBranches[0], + dayIndex: hit, movingElements: [], movingPositions: [], isHidden: true, + }); + assert.equal(empty.isVoid, true, '这一天卯应当是旬空'); + assert.ok(['真空', '假空', '旬空未判'].includes(empty.status), '逢空必得给个真假说法'); +}); + +test('右栏用神一格:上了卦说在哪一爻,不上卦说伏在哪一爻', async () => { + // 伏神之前这格写死在「不上卦」三个字上,伏神取出来了它也不改。 + // 把函数摘出来实跑,钉的是它吐什么字,不是它叫什么名字。 + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const i = client.indexOf('function useGodFact('); + assert.ok(i >= 0, '客户端里找不到 useGodFact'); + const src = client.slice(i, client.indexOf('\n function ', i + 10)); + assert.ok(src.length > 0, 'useGodFact 摘出来是空的'); + const useGodFact = new Function(`${src}\nreturn useGodFact;`)(); + + const onGua = useGodFact({ present: ['官鬼'], absent: [], picked: { label: '二爻(卦中独一)', why: '卦中独一' }, hidden: [] }); + assert.match(onGua, /取官鬼/, '上了卦却没说取哪一亲'); + assert.match(onGua, /二爻/, '上了卦却没说在哪一爻'); + assert.ok(!/不上卦/.test(onGua), '上了卦还说不上卦'); + + // 这一格正是本轮要修的那处:妻财不上卦,右栏得落到伏神上 + const reading = buildReading(castByNumbers(5, 2), { now: new Date(2026, 8, 29), question: '这单生意能赚钱吗' }); + assert.equal(reading.useGod.picked, null, '这一卦的妻财本该不上卦'); + const offGua = useGodFact(reading.useGod); + assert.match(offGua, /不上卦/, '不上卦得说不上卦'); + assert.match(offGua, /伏5爻/, '不上卦却没把伏神落在哪一爻说出来'); + const fu = reading.useGod.hidden[0]; + assert.ok(offGua.includes(fu.hushen), '右栏没带上伏神干支'); + assert.ok(!/本宫首卦亦无/.test(offGua), '明明取到伏神却说本宫首卦亦无'); +}); + +/* ---------- 旬空 · 月破 · 墓绝 ---------- */ +// 上一轮出伏说「旬空、月破、墓库与地支冲本包未做」,这一轮补齐。补的东西必须有出处, +// 而且算法要对得上书上的表——只断言「函数存在」的话,把算法改错照样全绿。 + +test('旬空歌诀六句与算法逐句对撞', async () => { + const A = await import('../miniapp/node/almanac.mjs'); + assert.equal(A.XUNKONG_SONG.length, 6, '歌诀存的不是六句'); + for (let x = 0; x < 6; x += 1) { + const line = A.XUNKONG_SONG[x]; + // 歌诀形如「甲子旬中戌亥空」:头两字旬名,中字,四、五两字是空亡,末字空 + const want = line.slice(4, 6); + const got = A.xunKong(x * 10).voidNames.join(''); + assert.equal(got, want, `${line}:算法算出「${got}空」,与歌诀不合`); + assert.equal(A.xunKong(x * 10).headName, line.slice(0, 2).slice(1), `${line} 旬首对不上`); + } + // 一旬十日同旬,日柱换了旬首不变 + const sameXun = [A.xunKong(40), A.xunKong(43), A.xunKong(49)]; + for (const k of sameXun) { + assert.equal(k.voidNames.join(''), '寅卯', '甲辰旬十日内空亡应始终是寅卯'); + } +}); + +test('旬空对着《增删卜易》两个卦例的日柱反推', async () => { + const A = await import('../miniapp/node/almanac.mjs'); + // 「辰月乙卯日占求财得家人之贲」,书中断「丑财持世遇旬空」——丑在空。 + const yiMao = [1, 11, 21, 31, 41, 51].find((i) => i % 12 === 3); + assert.ok(A.xunKong(yiMao).voidNames.includes('丑'), '乙卯日(丑当值)丑应旬空'); + // 「子月辛亥日占远行求财得大畜」,书中断「世值旬空」,该卦世爻正是寅木。 + const xinHai = [7, 17, 27, 37, 47, 57].find((i) => i % 12 === 11); + assert.ok(A.xunKong(xinHai).voidNames.includes('寅'), '辛亥日(寅当旬空)寅应旬空'); + // 六十日里每一天都恰属一旬,旬首两支之外的两支为空 + for (let d = 0; d < 60; d += 1) { + const k = A.xunKong(d); + assert.equal(k.voidBranches.length, 2, '每旬恒空两支'); + assert.equal(k.headName, ['子', '戌', '申', '午', '辰', '寅'][k.xun], '旬首与旬序对不上'); + } +}); + +test('月破逐月对得上《增删卜易》正月申破至十二月未破', async () => { + const A = await import('../miniapp/node/almanac.mjs'); + const months = ['寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥', '子', '丑']; + const song = ['申', '酉', '戌', '亥', '子', '丑', '寅', '卯', '辰', '巳', '午', '未']; + months.forEach((month, i) => { + const got = A.BRANCHES[A.monthPo(A.BRANCHES.indexOf(month))]; + assert.equal(got, song[i], `${month}月应破${song[i]},算得${got}`); + }); + // 月破就是月建所冲之支,两条路径必须给同一个答案 + for (let b = 0; b < 12; b += 1) { + assert.equal(A.monthPo(b), A.branchClash(b), '月破与六冲是同一件事'); + } +}); + +test('五行入墓各按自己的表,绝地则纳支里逢不上', async () => { + const A = await import('../miniapp/node/almanac.mjs'); + const J = await import('../miniapp/node/jingfang.mjs'); + const mu = { 金: '丑', 木: '未', 水: '辰', 土: '辰', 火: '戌' }; + for (const [element, want] of Object.entries(mu)) { + assert.equal(A.BRANCHES[A.muJue(element).mu], want, `${element}墓应在${want}`); + } + // 绝地是墓的下一支(金寅、木申、水土巳、火亥)。纳甲里每个五行只占两支 + // (金申酉、木寅卯、水子亥、火巳午、土丑辰未戌),逐个核下来没有一支落在五行自己 + // 占据的那两支里——所以任何一爻都逢不上绝地,卦体与断语的「绝」标据此撤掉。 + // 这里钉的是「绝确实逢不上」这个事实,不是绝地清单:将来谁动了纳支或墓表, + // 这个断言会先红,而不是让一个永不点亮的小标悄悄留在界面上。 + for (const element of Object.keys(mu)) { + const j = A.BRANCHES[(A.muJue(element).mu + 1) % 12]; + assert.notEqual(J.BRANCH_ELEMENTS[j], element, + `${element}的绝地${j}竟被纳给了${element}自己,「逢绝」重新成立,墓表与纳支得重核`); + } + for (const hexagram of HEXAGRAM_LIST) { + for (const line of J.jingfang(hexagram).lines) { + assert.equal(A.muJue(line.element).jue, undefined, '墓表里不该还留着绝地'); + } + } + // 《黄金策》口诀「春土、夏金、秋木、三冬逢火是真空」,四季各三月 + const expect = { 寅: '土', 卯: '土', 辰: '土', 巳: '金', 午: '金', 未: '金', 申: '木', 酉: '木', 戌: '木', 亥: '火', 子: '火', 丑: '火' }; + for (const [name, element] of Object.entries(expect)) { + assert.equal(A.seasonVacuous(A.BRANCHES.indexOf(name)).vacuousElement, element, `${name}月的真空元素应是${element}`); + } +}); + +test('假空真空照野鹤原话判:旺、动、生扶是假空,月破与四季之空是真空', async () => { + const J = await import('../miniapp/node/jingfang.mjs'); + const A = await import('../miniapp/node/almanac.mjs'); + // 卯木在酉月:金旺,木为「休」,所以既不旺相也不发动时最干净地落入真空 + const dayIndex = 40; // 甲辰旬,寅卯空 + const line = { position: 2, branchIndex: A.BRANCHES.indexOf('卯'), element: '木' }; + // 日支取酉:酉金不生卯木,这一爻才落得干净地进真空。 + // 早先取子水——子恰是水生木,「得日辰生扶」把本该真空的一爻救了回来,测的就不是真空了。 + const base = { monthBranch: 9, dayBranch: 9, dayIndex, movingElements: [], movingPositions: [] }; + + const quiet = J.voidReading(line, base); + assert.equal(quiet.isVoid, true, '卯在甲辰旬当旬空'); + assert.equal(quiet.status, '真空', '秋占木爻又不动,应作真空论'); + assert.ok(quiet.empties.some((w) => w.includes('秋令正空木')), '真空凭据里没有四季之空这一条'); + + // 「动不为空」:同一爻动起来就不再作真空论 + const moving = J.voidReading(line, { ...base, movingPositions: [2] }); + assert.equal(moving.status, '假空', '动爻不为空'); + assert.ok(moving.rescues.includes('发动'), '没把「发动」记作有救'); + + // 「有日建动爻生扶者不為空」:日辰来生也算有救。子水生卯木。 + const fed = J.voidReading(line, { ...base, dayBranch: 0 }); + assert.equal(fed.status, '假空', '得日辰生扶不作真空论'); + assert.ok(fed.rescues.some((w) => w.includes('生扶')), '没把「得日辰或动爻生扶」记作有救'); + + // 「月破爲空」:酉月破卯,本就该作真空 + const broken = J.voidReading(line, base); + assert.equal(broken.isBroken, true, '酉月正破卯'); + + // 不逢空的爻不该被扣上任何空破的帽子 + const shi = { position: 2, branchIndex: A.BRANCHES.indexOf('午'), element: '火' }; + const clear = J.voidReading(shi, base); + assert.equal(clear.isVoid, false); + assert.equal(clear.status, null, '不逢空就不该有真假之说'); +}); + +test('伏神本身逢旬空时,出伏结论真的落到「终不得出」', async () => { + // 上面那条出伏测试是读源码的:把「伏神直旬空」那一行删掉,它照样全绿。 + // 这一条扫真实的卦与真实的日子,非得找到一个伏神正逢旬空的组合不可。 + const A = await import('../miniapp/node/almanac.mjs'); + const voidDays = []; + for (let d = 0; d < 60 && voidDays.length < 12; d += 1) { + if (A.xunKong(d).voidNames.length === 2) voidDays.push(d); + } + assert.equal(voidDays.length, 12, '六十日里每旬两日旬空,计十二日'); + + let seen = 0; + let sawVoid = false; + for (let upper = 1; upper <= 8 && seen < 400; upper += 1) { + for (let lower = 1; lower <= 8 && seen < 400; lower += 1) { + const reading = buildReading(castByNumbers(upper, lower), { now: new Date(2026, 8, 30, 10, 0), question: '这单生意能赚钱吗' }); + seen += 1; + for (const fu of reading.useGod.hidden || []) { + const branch = fu.hushen[1]; + if (!A.xunKong(43).voidNames.includes(branch)) continue; + sawVoid = true; + assert.ok(/旬空|真空|假空/.test(fu.emerges.text), `伏神逢旬空却没把空论进去:${fu.emerges.text}`); + if (fu.emerges.key === '出不来') { + assert.ok(/旬空|真空/.test(fu.emerges.text), `断作终不得出却不点破旬空:${fu.emerges.text}`); + } + } + } + } + assert.ok(sawVoid, '这一轮扫遍八八六十四组,伏神就没逢上旬空,测不到那一支'); +}); + +test('伏神旬空而别无生扶时,那一条空要独自把它压在出不来里', async () => { + // 上一条只要求「逢空必得说出来」,可伏神一旦有生扶就判出得来,那个「空」被好话盖住了。 + // 这一条专挑无生扶的:把 bad 里「伏神直真空」那一行删掉,结论就再也压不住——得杀掉它。 + const A = await import('../miniapp/node/almanac.mjs'); + let checked = 0; + let verified = 0; + for (let upper = 1; upper <= 8 && checked < 64; upper += 1) { + for (let lower = 1; lower <= 8 && checked < 64; lower += 1) { + const reading = buildReading(castByNumbers(upper, lower), { now: new Date(2025, 0, 15, 10, 0), question: '这单生意能赚钱吗' }); + checked += 1; + for (const fu of reading.useGod.hidden || []) { + if (fu.emerges.key !== '出不来') continue; + if (!A.xunKong(A.dayPillar(2025, 1, 15).index).voidNames.includes(fu.hushen[1])) continue; + // 结论是「终不得出」,正文就必须把空点出来,且要分清真空假空 + assert.match(fu.emerges.text, /旬空而(真空|假空)/, `断作终不得出却不言空:${fu.emerges.text}`); + assert.match(fu.emerges.text, /终不得出/, '出不来这一路没走到'); + // 关键:空必须作为**出不来的一条凭据**出现,不只是句首提一句。 + // 只盯「旬空而真空」那个头字不够——那是 kongHead 拼的,把 bad 里那一行删掉照样过。 + assert.match(fu.emerges.text, /伏神直(真空|假空)/, `空没被算作出不来的一条:${fu.emerges.text}`); + // 假空要说清「空不为其患」,免得读成空就是死因 + if (/假空/.test(fu.emerges.text)) { + assert.ok(/空不为其患/.test(fu.emerges.text), `假空却把空当成死因:${fu.emerges.text}`); + } + verified += 1; + } + } + } + assert.ok(verified > 0, '这批卦里没有伏神旬空而出不来的,用例落空了'); +}); + +test('伏神休囚无气那一条真的在出不来里说得出来', async () => { + // 野鹤「终不得出」第一条就是「伏神正逢休、囚无气」。删掉这一句,结论还会是「出不来」, + // 只是没了凭据——所以要单独钉住那句人话,不只看 key。 + const A = await import('../miniapp/node/almanac.mjs'); + let checked = 0; + let rested = 0; + for (let upper = 1; upper <= 8; upper += 1) { + for (let lower = 1; lower <= 8; lower += 1) { + // 丑月月建土,火在丑月为休囚;一月的卦里总有伏神落在这上头 + const reading = buildReading(castByNumbers(upper, lower), { now: new Date(2025, 0, 15, 10, 0), question: '这单生意能赚钱吗' }); + checked += 1; + for (const fu of reading.useGod.hidden || []) { + if (fu.emerges.key !== '出不来') continue; + if (!/休囚无气|于月建[休囚死]/.test(fu.emerges.text)) continue; + rested += 1; + assert.match(fu.emerges.text, /休囚无气/, `断了休囚却没把「无气」说出来:${fu.emerges.text}`); + assert.match(fu.emerges.text, /于月建[休囚死]/, '没点明伏神于月建落到哪一档'); + } + } + } + assert.ok(rested > 0, '这批卦里没有伏神休囚而出不来的,用例落空了'); + assert.equal(checked, 64, '应当扫满八八六十四组'); +}); + +test('卦体与右栏把空破墓标出来', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const i = client.indexOf('function guaLines('); + const body = client.slice(i, client.indexOf('\n function ', i + 10)); + assert.ok(/states/.test(body), '卦体没收 states'); + assert.ok(/st\.void/.test(body) && /st\.broken/.test(body), '卦体没画旬空与月破'); + assert.ok(/st\.tomb/.test(body), '卦体没画入墓'); + assert.ok(!/st\.jue/.test(body), '纳支里逢不上绝地,卦体不该还留着绝这一标'); + // 四种情形各自成字,不能合成一个词了事 + const mark = client.slice(client.indexOf('const stateMark ='), client.indexOf('const stateMark =') + 700); + // 钉在「这一支真的会出这个字」上。钉 st.broken 出现过是不够的——把 '破' 换成 '' 时 + // st.broken 还在下面 word === '破' 里出现过,照样全绿,那等于没测月破。 + for (const [field, word] of [['void', '空'], ['broken', '破'], ['tomb', '墓']]) { + const re = new RegExp(`st\\.${field} \\? [^\\n]*${word}`); + assert.ok(re.test(mark), `${field} 那一支没出「${word}」字`); + } + // 本卦那一次调用得把 states 传下去 + // 边界别用后面某个字段名去截——文件里 reading.changed 出现在前头,会把这一段切没了。 + const call = client.slice(client.indexOf('left.append(guaBlock('), client.indexOf('left.append(guaBlock(') + 400); + assert.ok(/reading\.states/.test(call), '解读页本卦没把 states 传进卦体'); + // 右栏得有一格旬空月破 + assert.ok(/旬空月破/.test(client), '右栏没报旬空与月破'); + assert.ok(/headName/.test(client) && /brokenName/.test(client), '右栏没把旬首空亡与月破读出来'); +}); + +test('reading 带着旬空月破与六爻逢什么,断语用神段说得出真假', async () => { + // 今天丁未日(甲辰旬空寅卯)、丁酉月(破卯),木爻在这两个日子都不算好过 + const reading = buildReading(castByNumbers(3, 1), { now: new Date(2026, 8, 30, 10, 0), question: '这批货该不该进' }); + assert.equal(reading.void.headName, '辰', '丁未日属甲辰旬'); + assert.deepEqual(reading.void.names, ['寅', '卯'], '甲辰旬空寅卯'); + assert.equal(reading.void.brokenName, '卯', '酉月破卯'); + assert.equal(reading.states.length, 6, '六爻的状态要逐爻给'); + const mu = reading.states.filter((s) => s.void || s.broken || s.tomb); + assert.ok(mu.length > 0, '这一卦该有逢空逢破逢墓的爻'); + for (const s of mu) { + if (s.void) { + assert.ok(['假空', '真空', '旬空未判'].includes(s.voidKind), `逢空必得给真假,${s.voidKind} 不成话`); + } + } +}); + +test('买卖行话也认得出财运,不该因为措辞不像「赚钱」就断成没写问题', async () => { + // 浏览器实测时撞上的:「这批货该不该进」明明是问财,detectTopic 却一个都不认, + // 断语只好说「未写所问何事,取不出用神」。进货、货款、卖掉、货,这些才是买卖人真会打的字。 + const { detectTopic } = await import('../miniapp/node/topics.mjs'); + for (const q of ['这批货该不该进', '该不该进货', '这批货能卖掉吗', '货款什么时候回', '这笔买卖能赚吗']) { + assert.equal(detectTopic(q)?.key, 'wealth', `「${q}」该认作财运`); + } + // 补词不能抢走别的类:求测者问的确实是别的事时,照旧各归各 + for (const [q, key] of [ + ['要不要换工作', 'career'], + ['能不能复合', 'love'], + ['这房子该买吗', 'property'], + ['钥匙丢了在哪', 'journey'], + ['官司打得赢吗', 'dispute'], + ['我要不要起诉对方', 'dispute'], + ]) { + assert.equal(detectTopic(q)?.key, key, `「${q}」不该被买卖那批词抢走`); + } +}); + +/* ---------- 六神 ---------- */ +// 装卦时人人都会画的一列,日干定初爻起哪一神。《卜筮全书·卷之一·起六神决》 +// 「甲乙起青龍,丙丁起朱雀,戊日起勾陳,己日起螣蛇,庚辛起白虎,壬癸起玄武。(俱從下起至上。)」 + +test('六神歌诀与六行排列表逐格对撞', async () => { + const J = await import('../miniapp/node/jingfang.mjs'); + // 歌诀原文一个字都不许改 + assert.equal( + J.SIX_GOD_SONG, + '甲乙起青龙,丙丁起朱雀,戊日起勾陈,己日起螣蛇,庚辛起白虎,壬癸起玄武。(俱从下起至上。)', + '六神歌诀与《卜筮全书》原文不符', + ); + // 原文后面的排布表,自初爻起六格 + const table = [ + ['甲', '乙', '青龙 朱雀 勾陈 螣蛇 白虎 玄武'], + ['丙', '丁', '朱雀 勾陈 螣蛇 白虎 玄武 青龙'], + ['戊', null, '勾陈 螣蛇 白虎 玄武 青龙 朱雀'], + ['己', null, '螣蛇 白虎 玄武 青龙 朱雀 勾陈'], + ['庚', '辛', '白虎 玄武 青龙 朱雀 勾陈 螣蛇'], + ['壬', '癸', '玄武 青龙 朱雀 勾陈 螣蛇 白虎'], + ]; + let cells = 0; + for (const [first, second, want] of table) { + for (const stem of [first, second].filter(Boolean)) { + const got = J.sixGods(J.SIX_GOD_ORDER && ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'].indexOf(stem)).join(' '); + assert.equal(got, want, `${stem}日六神排得不对:得「${got}」,表作「${want}」`); + cells += 6; + } + } + assert.equal(cells, 60, '十个日干各六格共六十格,少一格就是漏了字'); + // 六十甲子日柱各归各神,一个都不能排不出 + for (let d = 0; d < 60; d += 1) { + const g = J.sixGods(d % 10); + assert.equal(g.length, 6, `日干序 ${d % 10} 排不出六神`); + assert.equal(new Set(g).size, 6, '六神不可重样'); + } +}); + +test('六神对着书上两个乾为天卦例逐爻对', async () => { + const J = await import('../miniapp/node/jingfang.mjs'); + const A = await import('../miniapp/node/almanac.mjs'); + const stemOf = (name) => A.STEMS.indexOf(name[0]); + // 「丙辰月甲子日(测卦得乾为天卦)」:初爻子水子孙临青龙,父戌土临玄武,逐爻如下 + const jia = J.sixGods(stemOf('甲子')); + assert.equal(jia[0], '青龙', '甲子日子水子孙该临青龙'); + assert.equal(jia[1], '朱雀', '二爻寅木妻财该临朱雀'); + assert.equal(jia[2], '勾陈', '三爻辰土父母该临勾陈'); + assert.equal(jia[3], '螣蛇', '四爻午火官鬼该临螣蛇'); + assert.equal(jia[4], '白虎', '五爻申金兄弟该临白虎'); + assert.equal(jia[5], '玄武', '上爻戌土父母该临玄武'); + // 「丙辰月戊子日」同一卦:初爻该临勾陈,上爻该临朱雀 + const wu = J.sixGods(stemOf('戊子')); + assert.equal(wu[0], '勾陈', '戊子日子水子孙该临勾陈'); + assert.equal(wu[5], '朱雀', '上爻戌土父母该临朱雀'); + assert.equal(wu.join(''), '勾陈螣蛇白虎玄武青龙朱雀', '戊子日六神排得不对'); +}); + +test('六神只说气氛,不改吉凶', async () => { + // 野鹤一派的老话:「吉凶全凭五行生克,情态方看六神吉凶。」六神一旦能改吉凶, + // 断卦的根就动摇了。头一版这个测试拿同一个 now 循环十次,日干压根没变,六神也压根没换, + // 等于什么都没验——得真的换日子,让六神换过一轮,吉凶仍纹丝不动才作数。 + const seen = new Set(); + for (let day = 1; day <= 28; day += 1) { + const reading = buildReading(castByNumbers(5, 2), { now: new Date(2026, 8, day, 10, 0), question: '这单生意能赚钱吗' }); + assert.equal(reading.sixGods.length, 6, `${day} 日六神没有排满六爻`); + seen.add(reading.sixGods[0]); + // 同一个卦、同一句话,只有日子在动 + assert.equal(reading.hexagram.name, '风泽中孚', '卦变了,说明这一轮不是只换日子'); + assert.equal(reading.verdict.key, '体克用', `${day} 日竟改动了吉凶`); + // label 是「生克 + 旺衰」合出来的总分,不是光看生克那一层。这一卦体克用 + // 本是生克小吉,可体卦当月令得旺,总分推上去一档,落到大吉。 + assert.equal(reading.verdict.relationVerdict, '小吉', `${day} 日生克那一层变了`); + assert.equal(reading.verdict.label, '大吉', `${day} 日竟改动了吉凶`); + // 徽章与结论行必须说的是同一个吉凶。从前徽章取生克那一层、结论行取总分, + // 同一屏一个说小吉一个说大吉,读的人只会挑一个信。 + assert.ok( + reading.verdict.summary.startsWith(`${reading.verdict.label}:`), + `${day} 日徽章说「${reading.verdict.label}」而结论行说「${reading.verdict.summary}」,两处吉凶不是一回事`, + ); + // 这一卦用神不上卦,走的是伏神那一路。断言就只认这一路的话—— + // 早先写成一句通用匹配,结果用神上卦那一路的话替它作了证,两路坏一路照样全绿。 + const text = reading.insights.find((item) => item.title === '用神').text; + assert.match(text, /伏神临(青龙|朱雀|勾陈|螣蛇|白虎|玄武)/, `${day} 日没说伏神临哪一神`); + assert.match(text, /成不成仍只由生克与旺衰定/, `${day} 日伏神那一路没说清六神不作判据`); + } + assert.ok(seen.size >= 4, `二十八天里初爻只轮到 ${seen.size} 种六神,八月里该转遍六神才对`); + // 六神意象表六神齐全,且各神各有所主,不能张冠李戴 + const J = await import('../miniapp/node/jingfang.mjs'); + for (const god of J.SIX_GOD_ORDER) { + assert.ok(J.SIX_GOD_MEANING[god], `${god} 没有意象`); + assert.ok(J.SIX_GOD_MEANING[god].element, `${god} 没有五行`); + assert.ok(J.SIX_GOD_MEANING[god].meaning.length > 4, `${god} 的意象太空`); + } + assert.ok(/喜庆/.test(J.SIX_GOD_MEANING.青龙.meaning), '青龙主喜庆'); + assert.ok(/口舌/.test(J.SIX_GOD_MEANING.朱雀.meaning), '朱雀主口舌'); + assert.ok(/盗贼/.test(J.SIX_GOD_MEANING.玄武.meaning), '玄武主盗贼'); +}); + +test('卦体画出六神一列,用神临哪一神断语说得出', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const i = client.indexOf('function guaLines('); + const body = client.slice(i, client.indexOf('\n function ', i + 10)); + assert.ok(/gods/.test(body), '卦体没收六神'); + assert.ok(/class="sg"/.test(body), '卦体没画六神这一列'); + assert.ok(/gods\[position - 1\]/.test(body), '六神没按爻位取,下标多半错了'); + // 本卦那次调用得把六神传下去 + const call = client.slice(client.indexOf('left.append(guaBlock('), client.indexOf('left.append(guaBlock(') + 460); + assert.ok(/reading\.sixGods/.test(call), '解读页本卦没把六神传进卦体'); + // 断语里的神必须是六神表里的一神,且与该爻实算对得上 + const reading = buildReading(castByNumbers(3, 1), { now: new Date(2026, 8, 30, 10, 0), question: '这批货该不该进' }); + const J = await import('../miniapp/node/jingfang.mjs'); + const text = reading.insights.find((item) => item.title === '用神').text; + const said = text.match(/用神临(青龙|朱雀|勾陈|螣蛇|白虎|玄武)/); + assert.ok(said, '用神段没说临哪一神'); + assert.equal(said[1], reading.sixGods[reading.useGod.picked.position - 1], '断语说的六神与实排对不上'); + // 用神上卦这一路自己的那半句,不能靠伏神那一路的话顶数 + assert.ok(reading.useGod.picked, '这一卦用神本该上卦,否则验错了路'); + assert.match(text, /成不成仍只由上面的生克与旺衰定/, '用神上卦那一路没说清六神不作判据'); + assert.ok(!/伏神临/.test(text), '用神上卦却说起伏神来了'); +}); + +test('回头生与回头克定的是变爻对本爻,五行上各占五对', async () => { + const J = await import('../miniapp/node/jingfang.mjs'); + // 两个原话定方向:变爻生本爻是回头生、变爻克本爻是回头克。 + // 「巽木变坎水,谓之化生,水回头以生木也,即以吉断。」 + assert.equal(J.transformRelation({ element: '木' }, { element: '水' }).key, '回头生', '木变水应作回头生'); + assert.equal(J.transformRelation({ element: '木' }, { element: '水' }).good, true, '书上明说回头生作吉断'); + // 「震木变乾金,谓之化克,金回头以克木也,即以凶推。」 + assert.equal(J.transformRelation({ element: '木' }, { element: '金' }).key, '回头克', '木变金应作回头克'); + assert.equal(J.transformRelation({ element: '木' }, { element: '金' }).good, false, '书上明说回头克作凶推'); + + // 五行上二十个有序组合,五类各五,不重不漏——钉分布,钉「某几个」会漏掉第四类。 + const tally = {}; + for (const from of ['木', '火', '土', '金', '水']) { + for (const to of ['木', '火', '土', '金', '水']) { + const r = J.transformRelation({ element: from }, { element: to }); + tally[r.key] = (tally[r.key] || 0) + 1; + } + } + assert.deepEqual(tally, { 回头生: 5, 回头克: 5, 化泄: 5, 化耗: 5, 化比和: 5 }); + + // 《卜筮正宗·十八问答第二问》把回头克的五种情形逐个点了出来,正是上表里 + // 「变爻克本爻」那五个组合。这张表在 jingfang 里是当作定义校验用的, + // 这里再对一次:表里五对真的都是变爻克本爻,且没有漏掉哪一对。 + assert.equal(J.HUI_TOU_KE_PAIRS.length, 5, '回头克的五行组合应恰好五对'); + for (const [moving, changed] of J.HUI_TOU_KE_PAIRS) { + assert.equal(J.transformRelation({ element: moving }, { element: changed }).key, '回头克', + `${moving}动变${changed}书上说是回头克`); + } + // 同章那句「彻底克尽」不是装饰,凶不凶要看落在哪一亲身上,这半句得在断语里 + assert.match(J.transformRelation({ element: '木' }, { element: '金' }).text, /彻底克尽.*用神.*忌神仇神/s); +}); + +test('化泄化耗化比和不配吉凶调子,书上没原话就不硬配', async () => { + const J = await import('../miniapp/node/jingfang.mjs'); + // 《增删卜易》只对回头生、回头克明说了吉凶。其余三个方向书上只给名目, + // 硬配一个吉凶就成了编,所以 good 一律为 null。 + for (const [moving, changed, key] of [ + ['土', '金', '化泄'], // 本爻生变爻 + ['金', '木', '化耗'], // 本爻克变爻 + ['火', '火', '化比和'], // 同行 + ]) { + const r = J.transformRelation({ element: moving }, { element: changed }); + assert.equal(r.key, key, `${moving}动变${changed}该是${key}`); + assert.equal(r.good, null, `${key}书上没定吉凶,good 不该有值`); + assert.ok(r.text.length > 8, `${key}连句话都没说`); + } +}); + +test('进退神歌诀十六对两两互为反面,且每一对本支同行', async () => { + const J = await import('../miniapp/node/jingfang.mjs'); + // 歌诀原文照录底本,底本此处「戍」是「戌」的异体 + assert.equal( + J.JIN_TUI_SONG, + '进神:亥化子,寅化卯,巳化午,申化酉,丑化辰,辰化未,未化戌,戍化丑。退神:子化亥,卯化寅,午化巳,酉化申,辰化丑,未化辰,戍化未,丑化戍。', + '进退神歌诀与《增删卜易·进退神章》原文不符', + ); + const jin = [['亥', '子'], ['寅', '卯'], ['巳', '午'], ['申', '酉'], ['丑', '辰'], ['辰', '未'], ['未', '戌'], ['戌', '丑']]; + for (const [from, to] of jin) { + assert.equal(J.jinTui(from, to)?.key, '进神', `${from}化${to}该是进神`); + assert.equal(J.jinTui(to, from)?.key, '退神', `${to}化${from}该是退神`); + assert.equal(J.BRANCH_ELEMENTS[from], J.BRANCH_ELEMENTS[to], `${from}与${to}不同行,不该出现在进退神里`); + } + // 歌诀里没有的不硬说 + for (const [from, to] of [['寅', '辰'], ['子', '午'], ['亥', '亥']]) { + assert.equal(J.jinTui(from, to), null, `${from}化${to}不在歌诀里,不该判进退`); + } +}); + +test('变爻只与本位动爻生克,不与他爻相干', async () => { + const reading = buildReading(castByNumbers(1, 7), { now: new Date(2026, 8, 30, 10, 0), question: '这批货该不该进' }); + const text = reading.insights.find((item) => item.title === '化爻 · 变出之爻').text; + // 《增删卜易》原话得摊开,否则看着像要把变爻拿去六爻通算 + assert.match(text, /能生克沖合本位之動爻,不能生克他爻/, '化爻段没交代变爻只认本位动爻'); + // 断语只该提本位动爻那一个六亲身份,不许把世爻应爻拉进来一起算 + const t = reading.transforms[0]; + assert.equal(t.relation, '回头克', '天山遁二爻午火动变姤卦二爻亥水应是回头克'); + assert.ok(!/世爻.*应爻/.test(text), '化爻段把世应扯进来了,那是「他爻」'); + // 逐个动爻都只报自己那一格 + for (const one of reading.transforms) { + const chg = reading.changedJingfang.lines[one.position - 1]; + assert.equal(one.changed, `${chg.stem}${chg.branch}${chg.element}`, '化爻报的变爻对不上变卦同位那一爻'); + assert.equal(one.changedRelative, chg.relative, '化爻报的变爻六亲对不上'); + } +}); + +test('变爻是变卦里的静爻,不许把动爻那份救应算到它头上', async () => { + const J = await import('../miniapp/node/jingfang.mjs'); + // 天泽履初爻丁巳火动,变出天水讼初爻戊寅木。丁未日甲辰旬空寅卯,寅正在空里; + // 变爻在变卦里是静的,可它占的爻位恰好就是动爻那位——一 careless 就把「发动」 + // 这条有救算给它,真空会翻成假空。所以这一例专盯这个:它必须落在真空。 + const reading = buildReading(castByNumbers(1, 18), { now: new Date(2026, 8, 30, 10, 0), question: '这批货该不该进' }); + const t = reading.transforms[0]; + assert.equal(t.position, 1, '该例动爻在初爻'); + assert.equal(t.changed, '戊寅木', '变爻该是戊寅木'); + assert.deepEqual(t.marks, ['化真空'], '变爻寅木逢空又落秋令正空,该作真空,不该被「发动」救成假空'); + // 反过来核一遍:真的把它当动爻问,同一爻立刻翻成假空。钉住这个反差, + // 免得日后有人把 movingPositions 传回去却以为结果没变。 + const chg = reading.changedJingfang.lines[0]; + const day = dayPillar(2026, 9, 30); + const calendar = { + monthBranch: monthPillar(2026, 9, 30).branch, dayBranch: day.branch, dayIndex: day.index, + movingElements: ['火'], movingPositions: [1], + }; + assert.equal(J.voidReading(chg, { ...calendar, movingPositions: [1] }).status, '假空', + '同一爻若误记为发动,应翻成假空——两路不一致就说明这组断言没钉住区别'); + assert.equal(J.voidReading(chg, { ...calendar, movingPositions: [] }).status, '真空', + '变爻按静爻问才是真空'); +}); + +test('化爻断语与结构化字段同源,且 sentence 不混进字段', async () => { + const reading = buildReading(castByNumbers(1, 2), { now: new Date(2026, 8, 30, 10, 0), question: '这批货该不该进' }); + const text = reading.insights.find((item) => item.title === '化爻 · 变出之爻').text; + const t = reading.transforms[0]; + // 丑化辰是进神,同时同属土为化比和,辰又正是土的墓——一行三件事 + assert.equal(t.jinTui, '进神', '丑化辰该是进神'); + assert.equal(t.relation, '化比和', '丑土变辰土同属土,该是化比和'); + assert.ok(t.marks.includes('化墓'), '辰正是土的墓,该标出化墓'); + // 断语正文里这一条的事实,字段里得对得上 + assert.ok(text.includes(`${t.label}${t.moving}${t.movingRelative}动`), '断语没报本爻的干支与六亲'); + assert.ok(text.includes(`变出${t.changed}${t.changedRelative}`), '断语没报变爻的干支与六亲'); + assert.ok(text.includes('进神') && text.includes('化比和') && text.includes('化墓'), '断语没把进退与化墓说出来'); + // sentence 只进断语,不进结构化字段 + assert.ok(!('sentence' in t), 'sentence 混进了结构化字段,正文与字段会各说各话'); +}); + +test('六爻皆静时化爻段明说无变卦,不空着不提', async () => { + const reading = buildReading(castByCoins([7, 8, 7, 8, 7, 8])); + assert.deepEqual(reading.transforms, [], '静卦不该有化爻'); + const text = reading.insights.find((item) => item.title === '化爻 · 变出之爻').text; + assert.match(text, /六爻皆静.*无变卦/, '静卦的化爻段该明说无变卦'); + assert.equal(reading.changedJingfang, null, '静卦不该有变卦京房'); +}); + +test('卦体在变卦上标出化出之爻,回头克与回头生加重', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const i = client.indexOf('function guaLines('); + const body = client.slice(i, client.indexOf('\n function ', i + 10)); + assert.ok(/transforms/.test(body), '卦体没收 transforms'); + assert.ok(/const tf = \(transforms \|\| \[\]\)\.find/.test(body), '化出之爻没按爻位对位'); + // 钉在「这一支真的会出『化』字与关系名」上,不是钉 transforms 出现过 + assert.ok(/class="hu\$\{tf\.relation === '回头克' \|\| tf\.relation === '回头生' \? ' hot' : ''\}"/.test(body), + '化爻小标没画出来,或回头克回头生没加重'); + // 直接写术语本身,不再加「化」字前缀——三个名目本就以「化」开头,加一遍就成了「化 化比和」 + assert.ok(/title="化出之爻">\$\{escapeHtml\(tf\.relation\)\}/.test(body), '化爻小标没写关系名'); + assert.ok(!/化 \$\{escapeHtml\(tf\.relation\)\}/.test(body), '化爻小标多了一个「化」前缀,读成了「化 化比和」'); + // 本卦那边不标:动爻本来就有 ○/×,再挤一记反而看不清 + const call = client.slice(client.indexOf('left.append(guaBlock('), client.indexOf('left.append(guaBlock(') + 400); + assert.ok(!/reading\.transforms/.test(call), '本卦不该标化出之爻'); + // 变卦那次得把它传下去 + const changedCall = client.slice(client.indexOf("guaBlock(reading.changed, null, '变卦'"), client.indexOf("guaBlock(reading.changed, null, '变卦'") + 200); + assert.ok(/reading\.transforms \|\| \[\]/.test(changedCall), '变卦没把化爻传进卦体'); + + // 颜色也得说真话:只有书上明写了一个吉一个凶的回头生、回头克用朱砂, + // 化泄化耗化比和书上没定吉凶,就跟伏神一样用淡字。别拿颜色替它表态。 + const css = client.slice(client.indexOf('.gua-line .rel .hu {'), client.indexOf('.gua-line .rel .hu.hot {')); + assert.ok(!/var\(--seal\)/.test(css), '化爻小标不该一律朱砂——没定吉凶的三类用朱砂等于替它们表态'); + const hot = client.slice(client.indexOf('.gua-line .rel .hu.hot {'), client.indexOf('.gua-line .rel .hu.hot {') + 200); + assert.ok(/var\(--seal\)/.test(hot), '回头生回头克该用朱砂'); +}); + +test('MCP 把化爻落成字段,变卦那一行带上动爻去向', async () => { + const { handleMcpRequest } = await import('../miniapp/node/mcp/divination-http.mjs'); + let raw = ''; + const response = { writeHead() { return this; }, end(chunk) { raw += chunk; return this; } }; + await handleMcpRequest({ + response, + body: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'divination_cast', arguments: { question: '这批货该不该进', method: 'numbers', upper: 1, lower: 2 } } }, + }); + const result = JSON.parse(raw).result; + const sc = result.structuredContent; + assert.ok(Array.isArray(sc.transforms) && sc.transforms.length > 0, 'MCP 没给 transforms'); + for (const t of sc.transforms) { + assert.ok(['回头生', '回头克', '化泄', '化耗', '化比和'].includes(t.relation), `关系名不在五类里:${t.relation}`); + assert.ok(/[金木水火土]/.test(t.moving) && /[金木水火土]/.test(t.changed), '干支五行没带全'); + assert.ok(t.movingRelative && t.changedRelative, '六亲没带全'); + } + // 抬头那一行:Agent 复述「变到哪儿、往哪儿去」看这一行就够 + assert.match(result.content[0].text, /【变卦】.+动爻去向 .+回头|动爻去向 .+化/s, '变卦行没带动爻去向'); +}); + +test('元神忌神仇神照《增删卜易》那一章定位,书上的金例一字不差', async () => { + const J = await import('../miniapp/node/jingfang.mjs'); + // 原文:「元神者,生用神之爻,即为元神。忌神者,克用神之爻也,即为忌神。仇神者,克制元神 + // 不能生用神,反生忌神而克害用神,即为仇神。假令金为用神,生金者土也,土为元神; + // 克金者火也,火为忌神;克土生火者木也,木为仇神。余仿此。」 + const circle = (element) => J.useGodCircle( + { lines: ['木', '火', '土', '金', '水'].map((e, i) => ({ position: i + 1, element: e })) }, + { element }, + ); + const god = circle('金'); + assert.deepEqual(god.elements, { yuan: '土', ji: '火', chou: '木' }, '书例:金用则土元火忌木仇'); + assert.equal(god.yuan.length, 1, '元神取生用神那一行的爻'); + assert.equal(god.ji.length, 1, '忌神取克用神那一行的爻'); + assert.equal(god.chou.length, 1, '仇神取克元神那一行的爻'); + // 余仿此:五行各一组,三行必然互异 + const want = { 木: ['水', '金', '土'], 火: ['木', '水', '金'], 土: ['火', '木', '水'], 水: ['金', '土', '火'] }; + for (const [element, [yuan, ji, chou]] of Object.entries(want)) { + const one = circle(element); + assert.deepEqual([one.elements.yuan, one.elements.ji, one.elements.chou], [yuan, ji, chou], + `${element}用则${yuan}元${ji}忌${chou}仇`); + } + // 仇神的路数是间接的:它不生用神,却反去生忌神——这条钉住,断语才不能说成「仇神克用神」 + for (const element of ['木', '火', '土', '金', '水']) { + const one = circle(element); + assert.ok(generates(one.elements.chou, one.elements.ji), `${element}用之仇神${one.elements.chou}该反生忌神${one.elements.ji}`); + assert.ok(!generates(one.elements.chou, element), `${element}用之仇神不该生用神`); + assert.ok(overcomes(one.elements.chou, one.elements.yuan), `${element}用之仇神该克的是元神,不是用神`); + } +}); + +test('用神那圈只在一个用神定下来时才有,卦外不借爻', async () => { + // 妻财不上卦的卦:用神取的是本宫首卦的伏神,在卦外,元忌仇无从谈起——照实不给 + const hidden = buildReading(castByNumbers(3, 1), { now: new Date(2026, 8, 30, 10, 0), question: '我最近身体如何' }); + assert.equal(hidden.useGod.picked, null, '这一例用神本该不上卦,否则验错了路'); + assert.equal(hidden.useGod.circle, null, '用神不在卦上就不该硬凑出一圈元忌仇'); + const circleSentence = (reading) => reading.insights.find((item) => item.title === '用神').text; + assert.ok(!/元神属/.test(circleSentence(hidden)), '不上卦却报出了元神'); + // 兑宫泽山咸:兄弟不上卦,婚恋两亲各看各的,也不该有圈 + const both = buildReading(castByNumbers(2, 5), { now: new Date(2026, 8, 30, 10, 0), question: '他会主动找我吗' }); + if (both.useGod.relatives.length > 1) { + assert.equal(both.useGod.circle, null, '两亲各看各的时无从取舍,不该有圈'); + assert.ok(!/元神属/.test(circleSentence(both)), '两亲各看各的却报出了元神'); + } +}); + +test('用神段把元忌仇的所在、动静、旺衰摆开,并守住「勿以仇神即仇人」', async () => { + const reading = buildReading(castByNumbers(3, 1), { now: new Date(2026, 8, 30, 10, 0), question: '这批货该不该进' }); + const text = reading.insights.find((item) => item.title === '用神').text; + const circle = reading.useGod.circle; + assert.ok(circle, '这一卦用神上了卦,该有这一圈'); + assert.deepEqual(circle.elements, { yuan: '水', ji: '金', chou: '土' }, '二爻木用神:水元金忌土仇'); + // 每一支都要报到「哪一爻、动不动、月建旺衰」——野鹤原话是「有元神動而生扶否?有忌神動而克害否?」 + // 动静有三档:动、暗动、静。暗动是静爻被日辰冲出来的,与动爻分列(暗动章与动散章各管一路)。 + for (const [name, positions] of [['元神', circle.yuan], ['忌神', circle.ji], ['仇神', circle.chou]]) { + assert.ok(new RegExp(`${name}属${circle.elements[name === '元神' ? 'yuan' : name === '忌神' ? 'ji' : 'chou']},见[\\s\\S]{0,40}((?:暗动|动|静),于月建[旺相休囚死])`).test(text), + `${name}没报出所在与动静旺衰`); + } + assert.match(text, /勿以仇神即仇人也/, '漏了「勿以仇神即仇人也」这句'); + assert.match(text, /并不直接克用神/, '没说清仇神是间接为害,说成了直接克就反了'); + // 结构化字段里存的是爻位号,每一个都要跟卦体上那一爻对得上 + const J = await import('../miniapp/node/jingfang.mjs'); + const jf = J.jingfang(reading.hexagram); + for (const position of [...circle.yuan, ...circle.ji, ...circle.chou]) { + assert.ok(position >= 1 && position <= 6, `爻位越界:${position}`); + assert.ok(jf.lines[position - 1], `第${position}爻不在卦上`); + } + for (const position of circle.ji) { + assert.equal(jf.lines[position - 1].element, circle.elements.ji, '忌神那支的五行不对'); + } + for (const position of circle.yuan) { + assert.equal(jf.lines[position - 1].element, circle.elements.yuan, '元神那支的五行不对'); + } +}); + +test('回头克落在用神、元神、忌神、仇神上各说一句,方向不许反', async () => { + const now = new Date(2026, 8, 30, 10, 0); + const tail = (u, l, question) => { + const reading = buildReading(castByNumbers(u, l), { now, question }); + const text = reading.insights.find((item) => item.title.startsWith('化爻')).text; + const cut = text.indexOf('这一爻'); + return { text: cut < 0 ? '' : text.slice(cut), circle: reading.useGod.circle }; + }; + // 用神:二爻官鬼火动,变出亥水子孙,水回头克火——用神遭回头克则凶 + const onGod = tail(1, 7, '我该不该换工作'); + assert.match(onGod.text, /正是用神.*原用二神遇之則凶.*实打实的凶/s, '用神遭回头克该断为凶'); + assert.ok(!/不作凶论/.test(onGod.text), '把用神遭回头克说成了不作凶论,方向反了'); + // 忌神:三爻回头克,卦中用神为初爻父母土,忌神是三爻卯木 + const onJi = tail(1, 8, '这房子该不该买'); + assert.match(onJi.text, /正落在忌神那一行.*忌仇二神遇之反吉.*不作凶论/s, '忌神遭回头克该反不作凶论'); + // 仇神 + const onChou = tail(1, 12, '这批货该不该进'); + assert.match(onChou.text, /正落在仇神那一行.*反不作凶论/s, '仇神遭回头克该反不作凶论'); + // 元神:原书未言,就明说未言,不替它定 + const onYuan = tail(1, 7, '这房子该不该买'); + assert.match(onYuan.text, /正落在元神那一行.*原书未言/s, '元神遇回头克该照实说原书未言'); + // 用神不上卦时那圈根本不存在,后半句就接不上——空口说「落在用神则凶」是编的 + const noCircle = tail(1, 7, '这批货该不该进'); + assert.equal(noCircle.circle, null, '这一例用神本该不上卦,否则验错了路'); + assert.equal(noCircle.text, '', '用神不在卦上还接「落在用神则凶」那半句,是空口说凶'); +}); + +test('卦体把元忌仇标在各自那一爻,忌神描边加重', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const i = client.indexOf('function guaLines('); + const body = client.slice(i, client.indexOf('\n function ', i + 10)); + assert.ok(/circle/.test(body), '卦体没收用神那圈'); + // 钉在「这一支真的会出这个字」上,不是钉 circle 出现过——三元入圈时能重复出 + assert.ok(/\(circle\.yuan \|\| \[\]\)\.includes\(position\) \? '元'/.test(body), '元神没按爻位对位'); + assert.ok(/\(circle\.ji \|\| \[\]\)\.includes\(position\) \? '忌'/.test(body), '忌神没按爻位对位'); + assert.ok(/\(circle\.chou \|\| \[\]\)\.includes\(position\) \? '仇'/.test(body), '仇神没按爻位对位'); + assert.ok(/class="role role-\$\{role\}"/.test(body), '元忌仇没画出标记'); + // 忌神直克用神,描边加重;元神仇神用淡字。 + // 窗口切到这条规则的收尾为止——按固定字数切会把后面 .role.god 那条(也是朱砂)算进来, + // 那样即便把忌神的朱砂删了照样全绿,等于没测。 + const rule = (selector) => { + const from = client.indexOf(selector); + assert.ok(from >= 0, `CSS 里找不到 ${selector}`); + return client.slice(from, client.indexOf('\n }', from)); + }; + assert.ok(/var\(--seal\)/.test(rule('.gua-line .rel .role-ji {')), '忌神该用朱砂描边——它是那一圈里真在使坏的一支'); + const shared = client.slice(client.indexOf('.gua-line .rel .role-yuan,'), client.indexOf('.gua-line .rel .role-ji {')); + assert.ok(!/var\(--seal\)/.test(shared), '元神仇神用淡字,不该一律朱砂'); + // 本卦那次得把 circle 传下去 + const call = client.slice(client.indexOf('left.append(guaBlock('), client.indexOf('left.append(guaBlock(') + 520); + assert.ok(/reading\.useGod\.circle/.test(call), '解读页本卦没把用神那圈传进卦体'); + // 变卦不传:那一圈是本卦的事 + const changedCall = client.slice(client.indexOf("guaBlock(reading.changed, null, '变卦'"), client.indexOf("guaBlock(reading.changed, null, '变卦'") + 220); + assert.ok(!/reading\.useGod\.circle/.test(changedCall), '变卦不该标用神那圈'); +}); + +test('MCP 把元忌仇那圈落成字段', async () => { + const { handleMcpRequest } = await import('../miniapp/node/mcp/divination-http.mjs'); + let raw = ''; + const response = { writeHead() { return this; }, end(chunk) { raw += chunk; return this; } }; + await handleMcpRequest({ + response, + body: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'divination_cast', arguments: { question: '这批货该不该进', method: 'numbers', upper: 3, lower: 1 } } }, + }); + const sc = JSON.parse(raw).result.structuredContent; + const circle = sc.useGod.circle; + assert.ok(circle, 'MCP 没给 useGod.circle'); + assert.deepEqual(circle.elements, { yuan: '水', ji: '金', chou: '土' }, '五行关系不对'); + for (const key of ['yuan', 'ji', 'chou']) { + assert.ok(Array.isArray(circle[key]), `${key} 该是爻位数组`); + for (const position of circle[key]) { + assert.ok(position >= 1 && position <= 6, `${key} 的爻位越界:${position}`); + } + } + // 三个位置不能在同一个爻位上撞车 + const all = [...circle.yuan, ...circle.ji, ...circle.chou]; + assert.equal(new Set(all).size, all.length, '元忌仇撞在同一爻上了'); +}); + +/* ---------- 暗动与日破(《增删卜易·暗动章第二十二》) ---------- */ + +/** 断语里「暗动 · 日破 · 冲散」那一段。 */ +const clashText = (reading) => { + const item = reading.insights.find((entry) => entry.title === '暗动 · 日破 · 冲散'); + return item ? item.text : ''; +}; + +test('暗动与日破按旺衰分两路:原章的坤之师卦例能一步步复现', async () => { + // 暗动章末尾那个卦例是本层最好的自证,因为它的每一环都写明了:寅月乙未日、占女痘、 + // 坤之师,酉金子孙为用神,二爻巳火动而克金,未日冲动丑土、土动生金。 + const J = await import('../miniapp/node/jingfang.mjs'); + const kun = hexagramByKey('000000'); + assert.equal(kun.name, '坤为地', '这一例的本卦该是坤为地'); + const jf = J.jingfang(kun); + // 世在上爻、应在三爻:八纯卦世六当,酉金子孙正持世,卯木官鬼为应,与原书所画对位 + assert.equal(jf.palaceName, '坤宫'); + assert.equal(jf.stage, '本宫'); + assert.equal(jf.shi, 6); + assert.equal(jf.ying, 3); + assert.equal(jf.lines[5].relative, '子孙', '占女痘以子孙为用神,上爻该是子孙酉金'); + assert.equal(jf.lines[5].branch, '酉'); + // 二爻乙巳火发动,逢之变出地水师——「坤之师」由此而来 + const changed = hexagramByKey('01' + kun.key.slice(2)); + assert.equal(changed.name, '地水师', '坤之二爻发动该变出地水师'); + assert.equal(jf.lines[1].branch, '巳'); + // 未日冲动四爻癸丑土:六冲一对,未丑相冲 + const { branchClash, BRANCHES, BRANCH_ELEMENTS } = await import('../miniapp/node/almanac.mjs'); + assert.equal(branchClash(7), 1, '未日所冲该是丑'); + assert.equal(jf.lines[3].branch, '丑'); + // 这一卦里丑土在寅月(当令木)落休囚,所以按原章定义它是日破,不是暗动 + assert.equal(J.vitality('土', BRANCH_ELEMENTS[2]).key, '死', '寅月土不当令'); + const result = J.dayClashReading(jf, { monthBranch: 2, dayBranch: 7, movingPositions: [2] }); + assert.deepEqual(result.dark.map((line) => line.position), [], '丑土在寅月休囚,不该判成暗动'); + assert.deepEqual(result.dayBroken.map((line) => line.position), [4], '该作日破的是四爻丑土'); + // 原书正是拿这一爻来生金救用神的——章中定义与卦例宽法在此处不一致。 + // 本包取章中定义那一路(见 dayClashReading 注释第二条),所以这里钉住日破, + // 免得日后有人按卦例把定义放宽了,还以为跟书一致。 + assert.equal(J.elementRelation('火', '土'), '生', '二爻巳火本生五爻丑土,卦例的救应由此来'); + assert.equal(J.elementRelation('土', '金'), '生', '丑土生上爻酉金'); + assert.equal(J.elementRelation('火', '金'), '克', '二爻巳火动而克用神酉金'); +}); + +test('暗动只认静爻:被日辰冲到的若正在发动,不并进暗动与日破', async () => { + const J = await import('../miniapp/node/jingfang.mjs'); + const { branchClash, BRANCHES } = await import('../miniapp/node/almanac.mjs'); + let checked = 0; + // 乾为天六爻纳支互异;水雷屯、山水蒙、天山遁、地风升则有两爻纳支相同, + // 一次能点到两爻。两种都走一遍,后一种才验得到「只退自己那一爻」。 + for (const order of [1, 3, 4, 11, 46]) { + const jf = J.jingfang(hexagramByOrder(order)); + for (let day = 0; day < 12; day += 1) { + const clashed = branchClash(day); + const hit = jf.lines.filter((line) => line.branchIndex === clashed); + if (hit.length === 0) continue; + for (const line of hit) { + // 这一爻静着:总该落进暗动或日破之一 + const still = J.dayClashReading(jf, { monthBranch: 0, dayBranch: day, movingPositions: [] }); + const quiet = [...still.dark, ...still.dayBroken].map((one) => one.position); + assert.ok(quiet.includes(line.position), `${BRANCHES[day]}日冲${line.branch},${line.label}静着却两路都不落`); + // 同一爻动起来:它就该从两路里退出去。动爻逢冲是「冲散」,归动散章,不在本章。 + // 注意不能断言「两路全空」——水雷屯初爻与上爻同纳子,只把初爻设成动爻, + // 上爻还静着,它照样被同一天冲到。钉这一爻自己退出去才是对的。 + const moving = J.dayClashReading(jf, { monthBranch: 0, dayBranch: day, movingPositions: [line.position] }); + const left = [...moving.dark, ...moving.dayBroken].map((one) => one.position); + assert.ok(!left.includes(line.position), `${line.label}已经在动了,不该还留着暗动或日破`); + checked += 1; + } + } + } + assert.ok(checked >= 18, `只验到 ${checked} 个动静对照,用例太薄`); +}); + +test('暗动与日破互斥:纳支重的一卦两爻同落,不会有又有破', async () => { + const J = await import('../miniapp/node/jingfang.mjs'); + // 六十四卦里有二十二卦两爻纳支相同(如水雷屯初爻与上爻同子),日辰只冲一支, + // 于是能一次点到两爻。但纳支相同则五行必同、旺衰必同,两爻必同落一侧。 + let sawPair = 0; + for (let order = 1; order <= HEXAGRAM_LIST.length; order += 1) { + const jf = J.jingfang(hexagramByOrder(order)); + const counts = new Map(); + for (const line of jf.lines) counts.set(line.branchIndex, (counts.get(line.branchIndex) || 0) + 1); + if ([...counts.values()].some((n) => n > 1)) sawPair += 1; + for (let month = 0; month < 12; month += 1) { + for (let day = 0; day < 12; day += 1) { + const r = J.dayClashReading(jf, { monthBranch: month, dayBranch: day, movingPositions: [] }); + assert.ok(r.dark.length === 0 || r.dayBroken.length === 0, + `${hexagramByOrder(order).name} ${month}月${day}日既有暗动又有日破,同一支不可能两样都占`); + assert.ok(r.dark.length + r.dayBroken.length <= 2, + `${hexagramByOrder(order).name} 一日冲到的爻超过两个`); + } + } + } + assert.ok(sawPair >= 20, `纳支重复的卦只数出 ${sawPair} 卦,样本文档没跟上`); +}); + +test('元神暗动谓之喜,忌神暗动谓之忌,两路都指得出爻位', () => { + // 乾为天六爻纳支子寅辰午申戌。用神取四爻午火:木为元神、水为忌神、金为仇神。 + const read = (date) => buildReading(castByCoins([7, 7, 7, 7, 7, 7]), + { now: new Date(2026, date[0], date[1], 10, 30), question: '我该不该换工作' }); + const circleOf = (r) => r.useGod.circle; + // 申日冲寅,寅木在亥子水令为相 → 二爻元神暗动 + const yuan = read([10, 18]); + assert.deepEqual(yuan.dayClash.dark, [2], '二爻元神该暗动'); + assert.deepEqual(yuan.dayClash.dayBroken, []); + assert.deepEqual(circleOf(yuan).yuan, [2], '二爻正是元神那一行'); + assert.match(clashText(yuan), /元神二爻暗动来生用神[\s\S]*谓之喜/); + assert.match(clashText(yuan), /用神休囚得元神暗動以相生/); + // 午日冲子,子水在申酉金令为死 → 初爻忌神日破。这一句要指向忌神 + const ji = read([0, 8]); + assert.deepEqual(ji.dayClash.dayBroken, [1], '初爻忌神该日破'); + assert.deepEqual(circleOf(ji).ji, [1]); + assert.match(clashText(ji), /初爻正是忌神那一行/); + assert.match(clashText(ji), /忌神暗动[\s\S]*谓之忌|忌神日破|初爻水(于月建死)为日破/); + + // 忌神暗动那一路另取一日:午日冲子,子在亥子令为休不取,换子月让水相 + const jiDark = buildReading(castByCoins([7, 7, 7, 7, 7, 7]), + { now: new Date(2026, 7, 12, 10, 30), question: '我该不该换工作' }); + assert.deepEqual(jiDark.dayClash.dark, [1], '初爻忌神该暗动'); + assert.deepEqual(circleOf(jiDark).ji, [1], '初爻正是忌神那一行'); + assert.match(clashText(jiDark), /忌神初爻暗动起来克害用神[\s\S]*谓之忌/); + assert.match(clashText(jiDark), /用神休囚無助,若遇忌神克害用神/); +}); + +test('用神不休囚时,把原书那层前提不齐的话点出来,不硬套', async () => { + const J = await import('../miniapp/node/jingfang.mjs'); + // 地风升初爻与四爻同纳丑土,未日冲动,丑土在丑月为旺,两爻一并暗动; + // 用神取三爻辛金,于丑月为相——不在原书「用神休囚」的前提下 + const reading = buildReading(castByCoins([8, 7, 7, 8, 8, 8]), + { now: new Date(2026, 0, 9, 10, 30), question: '我该不该换工作' }); + assert.deepEqual(reading.dayClash.dark, [1, 4], '这一例本该有两爻暗动,用例选错了'); + const god = reading.useGod.picked; + assert.equal(god.label, '三爻'); + const tone = J.vitality(god.element, reading.structure.monthElement).key; + assert.equal(tone, '相', '用神在丑月该是相,前提正是这一条不成立'); + const text = clashText(reading); + assert.match(text, /那一层前提并不齐备/, '用神不休囚时没有把前提说出来'); + assert.match(text, /本卦用神三爻于月建为相/, '没点明用神到底落在哪一档'); +}); + +test('仇神暗动与圈外暗动都只报事实,原书未言的两路不替它定', () => { + // 寅日冲申,五爻申金在丑土令为相 → 五爻仇神暗动 + const chou = buildReading(castByCoins([7, 7, 7, 7, 7, 7]), + { now: new Date(2026, 0, 16, 10, 30), question: '我该不该换工作' }); + assert.deepEqual(chou.dayClash.dark, [5], '五爻仇神该暗动'); + assert.deepEqual(chou.useGod.circle.chou, [5]); + assert.match(clashText(chou), /五爻正是仇神那一行/); + assert.match(clashText(chou), /仇神暗动归哪一支,原书未言,这里不替它定/); + assert.ok(!/谓之喜/.test(clashText(chou)), '仇神暗动被说成了喜'); + assert.ok(!/谓之忌/.test(clashText(chou)), '仇神暗动被说成了忌'); + + // 辰日冲戌,上爻戌土在丑土令为旺 → 上爻既非元神也非忌神,更非仇神 + const off = buildReading(castByCoins([7, 7, 7, 7, 7, 7]), + { now: new Date(2026, 0, 6, 10, 30), question: '我该不该换工作' }); + assert.deepEqual(off.dayClash.dark, [6], '上爻该暗动'); + const circle = off.useGod.circle; + assert.ok(!circle.yuan.includes(6) && !circle.ji.includes(6) && !circle.chou.includes(6), + '这一爻本来就不在那一圈上,用例选错了'); + const text = clashText(off); + assert.match(text, /对别的爻暗动只说「有喜有忌」,没再分派吉凶/); + assert.ok(!/谓之喜|谓之忌/.test(text), '圈外的暗动也被派了吉凶'); +}); + +test('用神定不下来时只摆暗动事实,不接喜忌那一半', () => { + // 妻财不上卦,用神取的是伏神,在卦外,元忌仇无从谈起 + const reading = buildReading(castByCoins([6, 7, 6, 7, 6, 7]), + { now: new Date(2026, 0, 12, 10, 30), question: '我该不该换工作' }); + assert.equal(reading.useGod.circle, null, '这一例本该没有那一圈'); + assert.ok(reading.dayClash.dark.length > 0, '这一例本该有暗动,用例落空了'); + const text = clashText(reading); + assert.match(text, /暗动章第二十二/); + assert.ok(!/谓之喜|谓之忌/.test(text), '没有圈却派了暗动的吉凶'); + assert.ok(!/正是元神|正是忌神|正是仇神/.test(text), '没有圈却点了身份'); +}); + +test('一卦两爻暗动时整行合说,旺衰不必逐爻重报', () => { + // 地风升初爻与四爻同纳丑土,未日冲动,丑土在丑月为旺,两爻一并暗动 + const reading = buildReading(castByCoins([8, 7, 7, 8, 8, 8]), + { now: new Date(2026, 0, 9, 10, 30), question: '我该不该换工作' }); + assert.equal(reading.hexagram.name, '地风升'); + assert.deepEqual(reading.dayClash.dark, [1, 4], '初爻与四爻同纳丑土,该一并暗动'); + const text = clashText(reading); + assert.match(text, /初爻土(于月建旺)、四爻土(于月建旺)皆为暗动/); + assert.match(text, /元神那一行在初爻、四爻都占着/); + assert.ok(!/正是元神那一行。正是元神/.test(text), '同一行被逐爻重复报了一遍'); +}); + +test('逐爻状态里暗动与日破各自标在那一爻上,且与结构化字段对得上', async () => { + const J = await import('../miniapp/node/jingfang.mjs'); + let sawDark = 0; + let sawBroken = 0; + for (let month = 0; month < 12; month += 1) { + for (let day = 1; day <= 28; day += 7) { + const reading = buildReading(castByCoins([7, 7, 7, 7, 7, 7]), + { now: new Date(2026, month, day, 10, 30), question: '我该不该换工作' }); + const jf = J.jingfang(reading.hexagram); + for (const st of reading.states) { + const line = jf.lines[st.position - 1]; + const isMoving = reading.movingLines.some((one) => one.position === st.position); + if (st.dark) { + sawDark += 1; + assert.ok(reading.dayClash.dark.includes(st.position), `逐爻标了暗动,dayClash.dark 里却没有${st.position}`); + assert.ok(!isMoving, `${line.label}是动爻,不该标暗动`); + assert.ok(['旺', '相'].includes(J.vitality(line.element, reading.structure.monthElement).key), + `${line.label}在月建不旺相,标不出暗动`); + } + if (st.dayBroken) { + sawBroken += 1; + assert.ok(reading.dayClash.dayBroken.includes(st.position), `逐爻标了日破,dayClash.dayBroken 里却没有${st.position}`); + assert.ok(!isMoving, `${line.label}是动爻,不该标日破`); + assert.ok(['休', '囚', '死'].includes(J.vitality(line.element, reading.structure.monthElement).key), + `${line.label}在月建不休囚,标不出日破`); + } + // 旬空那套不受影响:暗动必旺相,早被「旺不爲空」收走,两边不该打架 + if (st.dark && st.void) { + assert.equal(st.voidKind, '假空', `${line.label}既暗动又真空的话,旺相与真空打起来了`); + } + } + } + } + assert.ok(sawDark > 5, `只验到 ${sawDark} 处暗动,样本文档没铺开`); + assert.ok(sawBroken > 5, `只验到 ${sawBroken} 处日破,样本文档没铺开`); +}); + +test('卦体把暗动与日破标在各自那一爻,暗动不上朱砂', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const i = client.indexOf('function guaLines('); + const body = client.slice(i, client.indexOf('\n function ', i + 10)); + assert.ok(/st\.dark \? '暗'/.test(body), '卦体没标暗动'); + assert.ok(/st\.dayBroken \? '日破'/.test(body), '卦体没标日破'); + // 暗动不进朱砂名单。这条要直接钉在造 span 那一句上:暗动的吉凶要看它落在元神 + // 还是忌神头上,卦体这一格未必是那两行,替它表态就是编。改名单时最容易漏在这里—— + // 只查 CSS 的话,.st.po 与 .st 两条规则一个字都不会动,测试照样全绿。 + const span = body.slice(body.indexOf('.map((word) =>'), body.indexOf('.join(\'\');')); + assert.ok(span.includes("word === '日破'"), '日破该与月破同列朱砂'); + assert.ok(!span.includes("word === '暗'"), '暗动不该列进朱砂名单'); + // 窗口要切到这条规则的收尾——按固定字数切会把后面 .role.god(也是朱砂)算进来 + const rule = (selector) => { + const from = client.indexOf(selector); + assert.ok(from >= 0, `CSS 里找不到 ${selector}`); + return client.slice(from, client.indexOf('\n }', from)); + }; + assert.ok(/var\(--seal\)/.test(rule('.gua-line .rel .st.po,')), '日破该与月破一样用朱砂'); + const base = rule('.gua-line .rel .st {'); + assert.ok(!/var\(--seal\)/.test(base), '暗动不该染朱砂'); +}); + +test('用神段把暗动单列一档,不并进动爻也不并进静爻', () => { + // 三爻在这一日暗动,正是仇神那一行 + const reading = buildReading(castByNumbers(3, 1), + { now: new Date(2026, 0, 12, 10, 0), question: '这批货该不该进' }); + assert.deepEqual(reading.dayClash.dark, [3], '这一例本该三爻暗动,用例选错了'); + const circle = reading.useGod.circle; + assert.ok(circle.chou.includes(3), '三爻正是仇神那一行'); + const text = reading.insights.find((item) => item.title === '用神').text; + assert.ok(/三爻(暗动,于月建[旺相])/.test(text), `用神段没把三爻报成暗动:${text}`); + // 并进动或并进静都不行:暗动章与动散章各管一路,原书里它是独立的一档 + assert.ok(!/三爻(动,/.test(text), '暗动被并进了动爻'); + assert.ok(!/三爻(静,/.test(text), '暗动被并进了静爻'); +}); + +// MCP 走的是真实时钟(new Date()),换一天就换一组干支,同一组数字出不出暗动、 +// 日破、冲散也跟着变。抬头那一行要是钉死在一组数字上,哪天一换就静悄悄空跑了。 +// 所以扫遍六十四卦,取头一个真出结果的那一卦。 +// 1..8 × 1..8 恰好覆盖六十四卦;实测全年十二个日支里出得最少的那一支也命中 4 组。 +const mcpCast = async (upper, lower) => { + const { handleMcpRequest } = await import('../miniapp/node/mcp/divination-http.mjs'); + let raw = ''; + const response = { writeHead() { return this; }, end(chunk) { raw += chunk; return this; } }; + await handleMcpRequest({ + response, + body: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'divination_cast', arguments: { question: '我该不该换工作', method: 'numbers', upper, lower } } }, + }); + return JSON.parse(raw); +}; + +const scanMcpDayClash = async (wants) => { + for (let upper = 1; upper <= 8; upper += 1) { + for (let lower = 1; lower <= 8; lower += 1) { + const parsed = await mcpCast(upper, lower); + const clash = parsed.result.structuredContent.dayClash; + if (clash && wants(clash)) { + return { clash, header: mcpHeaderLine(parsed.result.content[0].text), text: parsed.result.content[0].text }; + } + } + } + return null; +}; + +const mcpHeaderLine = (text) => text.split('\n').find((line) => line.startsWith('【日冲】')) ?? null; + +test('MCP 把暗动与日破落成字段,抬头那一行该出就出、不该出不冒', async () => { + const hit = await scanMcpDayClash((clash) => clash.dark.length > 0 || clash.dayBroken.length > 0); + assert.ok(hit, '扫遍六十四卦都没撞出暗动或日破——抬头那一行等于没被验过'); + for (const position of [...hit.clash.dark, ...hit.clash.dayBroken]) { + assert.ok(position >= 1 && position <= 6, `爻位越界:${position}`); + } + assert.equal(hit.clash.dark.filter((p) => hit.clash.dayBroken.includes(p)).length, 0, + '暗动与日破落在了同一个爻位上'); + assert.ok(hit.header, `该出日冲那一行却没出:${hit.text.split('\n').slice(0, 8).join(' / ')}`); + if (hit.clash.dark.length > 0) { + assert.ok(hit.header.includes(`暗动${hit.clash.dark.join('、')}爻`), `抬头没点出暗动那一爻:${hit.header}`); + } + if (hit.clash.dayBroken.length > 0) { + assert.ok(hit.header.includes(`日破${hit.clash.dayBroken.join('、')}爻`), `抬头没点出日破那一爻:${hit.header}`); + } + // 反过来也钉住:三路皆空时那一行不许冒出来 + const quiet = await scanMcpDayClash((c) => c.dark.length === 0 && c.dayBroken.length === 0 && c.pressed.length === 0); + assert.ok(quiet, '扫遍六十四卦都没撞上三路皆空的一卦,「不该出」那半边没被验过'); + assert.equal(quiet.header, null, `一路皆空却出了日冲那一行:${quiet.header}`); +}); + +test('月破单独出现也作真空,且不再劝人「等逢冲」', async () => { + // 《增删卜易·旬空章》把「月破爲空」列在真空那几条里,所以只逢月破、不逢旬空的爻 + // 也该作真空;而《月破章》「虽有日辰之生,亦不能生」——冲救不了它,只会让它伤得更重。 + // 早先这里返回 status null,断语落到「暂看不出真假,等出旬或逢冲之日再定」, + // 那半句正是在劝人等一个救不回来的东西。 + const reading = buildReading(castByCoins([7, 7, 7, 7, 7, 7]), + { now: new Date(2026, 0, 1, 10, 30), question: '我该不该换工作' }); + const god = reading.useGod.picked; + const st = reading.states.find((one) => one.position === god.position); + assert.ok(st.broken, '这一例用神本该逢月破,用例选错了'); + assert.equal(st.void, false, '这一例本该不逢旬空,否则验的不是「单逢月破」这条路'); + assert.equal(st.voidKind, '真空', '单逢月破的爻该作真空'); + assert.deepEqual(st.empties, ['逢月破']); + const text = reading.insights.find((item) => item.title === '用神').text; + assert.match(text, /月破[^。]*是真空/, '月破没被判成真空'); + assert.ok(!/逢冲/.test(text.split('是真空')[1] || ''), '月破这一句还在劝人等逢冲——冲救不了月破'); + assert.match(text, /待出月、逢值再论/, '月破该说清待出月、逢值再论'); + // 标记里已经点过「月破」这个名,理由里不必再说第二遍 + assert.ok(!/月破[^,。]*,且逢月破/.test(text), '「月破」与「逢月破」重复说了一遍'); + + // 旬空而不月破时,「逢冲」仍然是可以等的——出旬与逢冲都救得了它 + const voidOnly = buildReading(castByCoins([7, 7, 7, 7, 7, 7]), + { now: new Date(2026, 0, 10, 10, 30), question: '我该不该换工作' }); + const vst = voidOnly.states.find((one) => one.position === voidOnly.useGod.picked.position); + assert.ok(vst.void && !vst.broken, '这一例本该只逢旬空'); + const vtext = voidOnly.insights.find((item) => item.title === '用神').text; + assert.match(vtext, /等出旬逢值或逢冲再论/, '旬空而不月破时,把「逢冲」也砍掉了'); +}); + +test('旬空又逢月破时,理由里既有月破也有季令之空', () => { + const reading = buildReading(castByCoins([8, 8, 8, 8, 8, 8]), + { now: new Date(2026, 8, 27, 10, 30), question: '我该不该换工作' }); + const st = reading.states.find((one) => one.position === reading.useGod.picked.position); + assert.ok(st.void && st.broken, '这一例本该旬空又逢月破,用例选错了'); + const text = reading.insights.find((item) => item.title === '用神').text; + assert.match(text, /旬空又月破/, '两样都逢时该两个名都点'); + assert.ok(!/,且逢月破/.test(text), '「月破」已在标记里,理由里不该再重复'); + assert.match(text, /且[^,。]*令正空/, '漏掉了季令正空那条真空的理由'); +}); + +/* ---------- 冲散(动散章第二十三) ---------- */ + +test('动散章原卦例逐环复现:涣之坎,上爻卯木发动,丑月丁酉日', async () => { + // 原文:「如丑月丁酉日占父出外一载無音得風水渙變坎卦…卯木父爻發動而生世, + // 又化子水回頭生許之,在外平安…此非卯動酉日沖之,何當散也。」 + // 这一例的价值在于:卯木在丑月落囚(休囚),照直觉该断散,原文偏偏断「在外平安」。 + const J = await import('../miniapp/node/jingfang.mjs'); + const H = await import('../miniapp/node/hexagrams.mjs'); + const A = await import('../miniapp/node/almanac.mjs'); + const hu = H.hexagramByOrder(59); + assert.equal(hu.name, '风水涣'); + const jf = J.jingfang(hu); + const top = jf.lines[5]; + assert.equal(top.branch, '卯', '上爻该是卯木'); + assert.equal(top.relative, '父母', '占父亲,父母为用神'); + assert.equal(top.position, 6); + // 二爻(索引5)发动变坎为水 + const kan = H.hexagramByKey(hu.key.slice(0, 5) + (hu.key[5] === '1' ? '0' : '1')); + assert.equal(kan.name, '坎为水', '上爻发动该变出坎为水'); + // 卯木发动而生世(世在五爻巳火),又化坎上爻子水回头生 + const shi = jf.lines[jf.shi - 1]; + assert.equal(jf.shi, 5, '世在五爻'); + assert.equal(shi.branch, '巳'); + assert.equal(J.elementRelation(top.element, shi.element), '生', '卯木本生巳火,这就是「發動而生世」'); + const kf = J.jingfang(kan); + assert.equal(J.transformRelation(top, kf.lines[5]).key, '回头生', '化子水回头生'); + // 丑月丁酉:酉日冲卯,而卯木在丑月落囚 + const now = new Date(2026, 0, 11, 10, 30); + assert.equal(A.BRANCHES[A.monthPillar(2026, 1, 11).branch], '丑', '月建该是丑'); + assert.equal(A.BRANCHES[A.dayPillar(2026, 1, 11).branch], '酉', '日支该是酉'); + assert.equal(A.branchClash(A.BRANCHES.indexOf('酉')), top.branchIndex, '酉日所冲正是卯'); + assert.equal(J.vitality('木', A.BRANCH_ELEMENTS[A.BRANCHES.indexOf('丑')]).key, '囚', '卯木在丑月落囚'); + + // 用本包把它起出来:上爻发动 → 冲散;化爻回头生 + const reading = buildReading(castByCoins([8, 7, 8, 8, 7, 9]), + { now, question: '我父亲出外一载无音,何时回' }); + assert.equal(reading.hexagram.name, '风水涣'); + assert.equal(reading.changed.name, '坎为水'); + assert.deepEqual(reading.movingLines.map((line) => line.position), [6]); + assert.deepEqual(reading.dayClash.pressed, [6], '上爻动而逢酉日冲,该落冲散'); + assert.deepEqual(reading.dayClash.dark, [], '没有静爻逢冲,不该出暗动'); + assert.deepEqual(reading.dayClash.dayBroken, [], '动爻不走日破那一路'); + assert.equal(reading.transforms[0].relation, '回头生'); + // 断语照章说话:报出事实,引原书「不散」的结论,不拿它断凶 + const text = clashText(reading); + assert.match(text, /动散章第二十三/); + assert.match(text, /上爻木(于月建囚)逢日冲,谓之冲散/); + assert.match(text, /休囚者間有沖散,亦千百中之一二/); + assert.match(text, /不拿它断凶/); + assert.ok(!/福來而不知/.test(text), '没有暗动却搬了驳暗动迟缓的那两句'); +}); + +test('冲散与暗动、日破三路互斥,同一卦里也不打架', async () => { + const J = await import('../miniapp/node/jingfang.mjs'); + let sawPress = 0; + for (let order = 1; order <= HEXAGRAM_LIST.length; order += 1) { + const jf = J.jingfang(hexagramByOrder(order)); + for (let month = 0; month < 12; month += 1) { + for (let day = 0; day < 12; day += 1) { + for (let mask = 1; mask < 64; mask += 1) { + const moving = []; + for (let k = 0; k < 6; k += 1) if (mask >> k & 1) moving.push(k + 1); + const r = J.dayClashReading(jf, { monthBranch: month, dayBranch: day, movingPositions: moving }); + const all = [...r.dark, ...r.dayBroken, ...r.pressed].map((line) => line.position); + assert.equal(new Set(all).size, all.length, `${hexagramByOrder(order).name} 同一爻落进两路去了`); + if (r.pressed.length > 0) { + sawPress += 1; + const movingSet = new Set(moving); + for (const line of r.pressed) { + assert.ok(movingSet.has(line.position), '冲散只该收动爻'); + } + } + // 冲散与暗动/日破可以同卦并存(不同爻),但三路绝不同落一爻 + assert.ok(r.dark.length === 0 || r.dayBroken.length === 0, + '暗动与日破同落一卦,说明那支纳支的两爻五行竟然不同'); + } + } + } + } + assert.ok(sawPress > 1000, `只验到 ${sawPress} 次冲散,样本文档没铺开`); +}); + +test('冲散只由日辰决定,月建一分不参与——《易冒》「苟非月建」那条豁免', async () => { + // 《易冒·日冲章》:「如動爻遇日辰相沖,苟非月建,則謂之散」——「苟非月建」四字 + // 就是豁免:只有月建冲的动爻不作冲散,它走的是月破那一路。 + // 所以判据不是「这一卦有没有月破」,而是**冲散这个结果与 monthBranch 无关**: + // 同一个日辰下,把月建从子换到亥,冲散那几爻一个都不许变。 + const J = await import('../miniapp/node/jingfang.mjs'); + const A = await import('../miniapp/node/almanac.mjs'); + const picked = (r) => [...r.pressed].map((line) => line.position).sort(); + let compared = 0; + let sawMonthOnly = 0; + for (let order = 1; order <= HEXAGRAM_LIST.length && compared < 400; order += 1) { + const jf = J.jingfang(hexagramByOrder(order)); + for (const mask of [0b111111, 0b010101, 0b101010, 0b001010]) { + const moving = []; + for (let k = 0; k < 6; k += 1) if (mask >> k & 1) moving.push(k + 1); + for (let day = 0; day < 12 && compared < 400; day += 1) { + const results = []; + for (let month = 0; month < 12; month += 1) { + const r = J.dayClashReading(jf, { monthBranch: month, dayBranch: day, movingPositions: moving }); + results.push(JSON.stringify(picked(r))); + // 顺带验一条:只有月建冲、而日辰不冲的动爻,既不进冲散,也落月破 + if (A.branchClash(month) !== day) { + for (const line of jf.lines) { + if (!moving.includes(line.position)) continue; + if (line.branchIndex === A.branchClash(month) && line.branchIndex !== A.branchClash(day)) { + sawMonthOnly += 1; + assert.ok(!r.pressed.some((one) => one.position === line.position), + `${line.label}只被月建冲到,不该算日辰冲散`); + assert.ok(r.pressed.every((one) => one.position !== line.position)); + } + } + } + } + assert.equal(new Set(results).size, 1, + `${hexagramByOrder(order).name} 日辰${A.BRANCHES[day]}不动、月建却改变了冲散的结果`); + compared += 1; + } + } + } + assert.ok(compared >= 300, `只比了 ${compared} 组,样本太薄`); + assert.ok(sawMonthOnly > 0, '一例「只逢月建冲的动爻」都没取到,那半条豁免等于没验'); +}); + +test('用神段把「动而逢日冲」单列一档,不并进动爻也不并进暗动', () => { + // 乾为天六爻纳甲子寅辰午申戌,四爻午火被日冲且正在发动,而四爻正是用神 + const reading = buildReading(castByCoins([7, 9, 7, 9, 7, 9]), + { now: new Date(2026, 0, 2, 10, 30), question: '我该不该换工作' }); + assert.deepEqual(reading.dayClash.pressed, [4], '这一例本该四爻冲散,用例选错了'); + const god = reading.useGod.picked; + assert.equal(god.position, 4, '用神该正是那一爻'); + const text = reading.insights.find((item) => item.title === '用神').text; + // 用神自己那一爻的动静与元神忌神仇神同一口径,都是四档。 + // 早先这里只有动静两档,于是用神自己冲散了也只写「(动)」,看不出来。 + assert.ok(new RegExp(`${god.label}(动而逢日冲)`).test(text), + `用神段没把用神自己报成动而逢日冲:${text}`); + assert.ok(!new RegExp(`${god.label}(动)`).test(text), '动而逢日冲被并进了动爻'); + assert.ok(!new RegExp(`${god.label}(暗动)`).test(text), '动而逢日冲被并进了暗动'); + assert.ok(!new RegExp(`${god.label}(静)`).test(text), '动而逢日冲被并进了静爻'); + // 静爻仍照旧不标档,免得满屏都是「(静)」 + const still = buildReading(castByCoins([7, 7, 7, 7, 7, 7]), + { now: new Date(2026, 0, 6, 10, 30), question: '我该不该换工作' }); + const stext = still.insights.find((item) => item.title === '用神').text; + assert.ok(!/(静)/.test(stext), `静爻不该挂一个「(静)」:${stext}`); +}); + +test('元神冲散时,用神段点名元神那一行', () => { + // 二爻寅木被日冲且在动,对用神水而言正是元神 + const reading = buildReading(castByCoins([7, 9, 7, 9, 7, 9]), + { now: new Date(2026, 0, 10, 10, 30), question: '我该不该换工作' }); + assert.deepEqual(reading.dayClash.pressed, [2]); + assert.deepEqual(reading.useGod.circle.yuan, [2], '二爻正是元神那一行'); + const text = reading.insights.find((item) => item.title === '用神').text; + assert.ok(/二爻(动而逢日冲,于月建[旺相休囚死])/.test(text), `用神段没把二爻报成动而逢日冲:${text}`); + assert.ok(!/二爻(动,/.test(text), '二爻被并进了动爻那一档'); +}); + +test('暗动与冲散可以同卦并存,各说各的', () => { + // 水雷屯初爻与上爻同纳子水,上爻发动;午日冲子。 + // 静的那一爻旺相作暗动,动的那一爻作冲散——同一天、同一个支,两路各归各。 + const reading = buildReading(castByCoins([7, 8, 8, 8, 7, 6]), + { now: new Date(2026, 7, 12, 10, 30), question: '我该不该换工作' }); + assert.equal(reading.hexagram.name, '水雷屯'); + assert.deepEqual(reading.movingLines.map((line) => line.position), [6]); + assert.deepEqual(reading.dayClash.dark, [1], '初爻静而逢冲,当作暗动'); + assert.deepEqual(reading.dayClash.pressed, [6], '上爻动而逢冲,当作冲散'); + assert.deepEqual(reading.dayClash.dayBroken, [], '两爻都旺相,不该出日破'); + const text = clashText(reading); + assert.match(text, /暗动章第二十二/, '暗动那一路该引暗动章'); + assert.match(text, /动散章第二十三|谓之冲散/, '冲散那一路该引动散章'); + assert.ok(text.indexOf('暗动') < text.indexOf('谓之冲散'), '暗动在前、冲散在后'); +}); + +test('旺相的动爻冲散,原书直言冲之不散', () => { + const reading = buildReading(castByCoins([7, 9, 7, 9, 7, 9]), + { now: new Date(2026, 0, 6, 10, 30), question: '我该不该换工作' }); + assert.deepEqual(reading.dayClash.pressed, [6]); + assert.match(clashText(reading), /于月建旺相,原书直言「旺相者沖之不散」/); +}); + +test('卦体把冲散标在动爻上,且不拿朱砂替它表态', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const i = client.indexOf('function guaLines('); + const body = client.slice(i, client.indexOf('\n function ', i + 10)); + assert.ok(/st\.pressed \? '冲散'/.test(body), '卦体没标冲散'); + // 冲散不上朱砂:动散章整章的结论是「冲之不散」,染红等于替它断凶。 + // 钉在造 span 那一句上——只查 CSS 的话,.st.po 与 .st 两条规则一个字都不会动。 + const span = body.slice(body.indexOf('.map((word) =>'), body.indexOf(".join('');")); + assert.ok(!span.includes("word === '冲散' ? ' po'"), '冲散不该进朱砂名单'); + assert.ok(span.includes('冲散'), '冲散那个 span 得留着'); +}); + +test('MCP 把冲散落进 dayClash,抬头那一行改叫【日冲】并点出冲散', async () => { + // 冲散是动散章那一路,只在动爻逢日冲时出。抬头里它得自己占一段, + // 不能只靠「三路有任一路非空就出一行」蒙混过去——那一行在、却没写冲散,是另一种错。 + const hit = await scanMcpDayClash((clash) => clash.pressed.length > 0); + assert.ok(hit, '扫遍六十四卦都没撞出冲散——抬头上那一段等于没被验过'); + const { clash, header, text } = hit; + for (const position of clash.pressed) { + assert.ok(position >= 1 && position <= 6, `冲散爻位越界:${position}`); + } + const all = [...clash.dark, ...clash.dayBroken, ...clash.pressed]; + assert.equal(new Set(all).size, all.length, '三路落到了同一个爻位上'); + assert.ok(header, '抬头上没有日冲那一行'); + assert.ok(header.includes(`冲散${clash.pressed.join('、')}爻`), + `抬头没点出冲散那一爻:抬头作「${header}」,而 dayClash.pressed 作 [${clash.pressed}]`); + assert.match(text, /谓之冲散/, '正文里没有冲散那一段'); +}); + +/* ---------- 六冲(增删卜易·六冲章第二十、六合章第十九) ---------- */ + +const clashSection = (reading) => reading.insights.find((item) => item.title === '六冲'); + +test('六冲卦十个、六合卦八个,名单逐一钉死', async () => { + const { hexagramClash } = await import('../miniapp/node/jingfang.mjs'); + const chong = []; + const he = []; + for (const hexagram of HEXAGRAM_LIST) { + const clash = hexagramClash(hexagram); + if (clash.chong) chong.push(hexagram.name); + if (clash.he) he.push(hexagram.name); + } + // 八纯卦加天雷无妄、雷天大壮。无妄与大壮之所以也在内:乾与震纳甲同支, + // 上下互易之后三对照样全冲。 + assert.deepEqual(chong, ['乾为天', '坤为地', '天雷无妄', '坎为水', '离为火', + '雷天大壮', '震为雷', '艮为山', '巽为风', '兑为泽']); + assert.deepEqual(he, ['地天泰', '天地否', '雷地豫', '山火贲', '地雷复', '泽水困', + '火山旅', '水泽节']); + // 一卦不能又冲又合:那要三对同时既冲又合 + for (const hexagram of HEXAGRAM_LIST) { + const clash = hexagramClash(hexagram); + assert.ok(!(clash.chong && clash.he), `${hexagram.name}又算六冲又算六合`); + } +}); + +test('六冲六合按初四、二五、三六配对,且一组成立则三组皆成立', async () => { + const { hexagramClash } = await import('../miniapp/node/jingfang.mjs'); + // 这一步最易数错:纳甲内外两卦的起支错开一位,配对是隔三位,不是内外同位。 + // 按内外同位去配,六十四卦里一个六冲卦也找不出来。 + for (const hexagram of HEXAGRAM_LIST) { + const clash = hexagramClash(hexagram); + assert.deepEqual(clash.pairs.map((pair) => [pair.lower.position, pair.upper.position]), + [[1, 4], [2, 5], [3, 6]], `${hexagram.name}的配对位不对`); + // 「这三组,只要有一组相冲,其他两组必定相冲」——不是经验,是纳甲定死的 + const chongPairs = clash.pairs.filter((pair) => pair.kind === '冲').length; + const hePairs = clash.pairs.filter((pair) => pair.kind === '合').length; + assert.equal(clash.chong, chongPairs === 3, `${hexagram.name}三对冲的组数与判定不符`); + assert.equal(clash.he, hePairs === 3, `${hexagram.name}三对合的组数与判定不符`); + assert.equal(clash.chong, chongPairs > 0, `${hexagram.name}「一组冲则三组皆冲」这条不成立`); + assert.equal(clash.he, hePairs > 0, `${hexagram.name}「一组合则三组皆合」这条不成立`); + } + // 乾为天三对的具体支,别只钉住「是六冲卦」这句话 + const qian = hexagramClash(hexagramByOrder(1)); + assert.deepEqual(qian.pairs.map((pair) => `${pair.lower.branch}${pair.upper.branch}`), + ['子午', '寅申', '辰戌']); +}); + +test('六冲章那六种冲里的四路,各自落到本卦上', async () => { + // 第一路日月冲爻归日辰与月建,已在暗动章与月破里逐爻算过,这里数的是剩下几路。 + const sixChongToChong = buildReading(castByCoins([6, 6, 6, 6, 6, 6]), + { now: new Date(2026, 0, 10, 10, 30), question: '我该不该换工作' }); + assert.equal(sixChongToChong.hexagram.name, '坤为地'); + assert.equal(sixChongToChong.changed.name, '乾为天'); + assert.ok(sixChongToChong.clash.chong, '坤为地该是六冲卦'); + assert.ok(sixChongToChong.clash.changedChong && sixChongToChong.clash.chongToChong, + '坤为地变乾为天,两头都是六冲卦,该作六冲变六冲'); + assert.match(clashSection(sixChongToChong).text, /本卦六冲、变卦也是六冲(六冲变六冲)/); + + const heToChong = buildReading(castByCoins([7, 6, 6, 6, 6, 6]), + { now: new Date(2026, 0, 10, 10, 30), question: '我该不该换工作' }); + assert.equal(heToChong.hexagram.name, '地雷复', '用例选错了卦'); + assert.ok(heToChong.clash.he, '地雷复该是六合卦'); + assert.ok(heToChong.clash.heToChong, '地雷复变乾为天,该作六合变六冲'); + assert.equal(heToChong.clash.chongToChong, false, '本卦不是六冲卦,不该同时算六冲变六冲'); + assert.match(clashSection(heToChong).text, /本卦六合、变卦六冲(六合变六冲)/); + + const transformClash = buildReading(castByCoins([8, 6, 6, 6, 6, 6]), + { now: new Date(2026, 0, 10, 10, 30), question: '我该不该换工作' }); + assert.deepEqual(transformClash.clash.transformClash, [2, 3], '这一例本该二爻三爻动爻变冲'); + assert.match(clashSection(transformClash).text, /变出去的那一支正好冲本位那一爻(动爻变冲)/); +}); + +test('卦内两爻相冲不等于六冲卦,零散的那几对只在卦体已出段时顺带报', async () => { + // 地泽临:兑下纳巳卯丑、坤上纳丑亥酉。初巳冲五亥、二卯冲上酉,各撞上一对。 + const lin = buildReading(castByCoins([7, 7, 6, 6, 6, 6]), + { now: new Date(2026, 8, 30, 10, 30), question: '我该不该换工作' }); + assert.equal(lin.hexagram.name, '地泽临', '用例选错了卦'); + assert.deepEqual(lin.clash.incidental, [[1, 5], [2, 6]]); + assert.equal(lin.clash.chong, false, '地泽临三对标准位不冲,就不是六冲卦'); + assert.equal(lin.clash.he, false); + // 零散爻冲六十四卦里有三十卦都有,单拿它当触发会让大半卦都多出这一段 + // ——同一卦换成不动上爻那一组(变出天风姤,不是六冲卦),整段就不出 + const quiet = buildReading(castByCoins([9, 7, 6, 6, 6, 6]), + { now: new Date(2026, 8, 30, 10, 30), question: '我该不该换工作' }); + assert.equal(quiet.hexagram.name, '地泽临', '用例选错了卦'); + assert.deepEqual(quiet.clash.incidental, [[1, 5], [2, 6]], '卦内那两对冲还在'); + assert.equal(quiet.clash.changedChong, false, '这一例变出天风姤,本该不是六冲卦'); + assert.equal(clashSection(quiet), undefined, '只为卦内零散爻冲就开段,那是噪音'); + // 同一卦变出六冲卦时它就顺带被报出来——上面那一组正变出乾为天 + assert.ok(lin.clash.changedChong); + assert.match(clashSection(lin).text, /卦里另有初爻巳冲五爻亥、二爻卯冲上爻酉/); + assert.match(clashSection(lin).text, /不等于本卦就是六冲卦/); +}); + +test('六冲的吉凶只按用神说,用神定不下来就不接那一层', async () => { + // 章末:「亦必兼用神而言,用神若旺,虽冲不碍;用神失陷,凶而又凶。」 + // 断言要认「照……这层冲……」那半句实说的话,不能只认引文里那半句——每段都引着它, + // 拿引文当判据,改口了照样全绿。 + const strong = buildReading(castByCoins([6, 6, 6, 6, 6, 6]), + { now: new Date(2026, 1, 10, 10, 30), question: '我该不该换工作' }); + assert.match(clashSection(strong).text, /用神三爻木于月建为旺/, '这一例本该用神旺相'); + assert.match(clashSection(strong).text, /照「用神若旺,虽冲不碍」,这层冲不碍着它/); + assert.ok(!/这层冲对它不是好事/.test(clashSection(strong).text), '用神旺相却按失陷说了'); + + const weak = buildReading(castByCoins([6, 6, 6, 6, 6, 6]), + { now: new Date(2026, 0, 10, 10, 30), question: '我该不该换工作' }); + assert.match(clashSection(weak).text, /用神三爻木于月建为囚/); + assert.match(clashSection(weak).text, /落在失陷那一头,照「用神失陷,凶而又凶」,这层冲对它不是好事/); + assert.ok(!/照「用神若旺,虽冲不碍」,这层冲不碍着它/.test(clashSection(weak).text), '用神失陷却说它不碍'); + + // 用神不上卦时那一圈是空的,吉凶那一层就悬着,不拿别的爻顶上 + const blank = buildReading(castByCoins([6, 6, 7, 7, 6, 7]), + { now: new Date(2026, 0, 10, 10, 30), question: '这场官司能了结吗' }); + assert.equal(blank.hexagram.name, '火山旅', '用例选错了卦'); + assert.ok(blank.clash.heToChong, '这一例本该是六合变六冲'); + assert.equal(blank.useGod.circle, null, '这一例本该取不出那一圈,用例选错了'); + assert.match(clashSection(blank).text, /用神定不下来,这一层就不接/); +}); + +test('「占凶事宜、占吉事不宜」那半句只引不裁,疾病那条只引不选边', async () => { + // 所问算吉事还是凶事,是问卦人自己的定位,一句问题里读不出来 + const plain = buildReading(castByCoins([6, 6, 6, 6, 6, 6]), + { now: new Date(2026, 0, 10, 10, 30), question: '我该不该换工作' }); + assert.match(clashSection(plain).text, /所问算吉事还是凶事,是你自己的定位,本包不替你归这一头/); + // 近病与久病差着一条命,只有问的人知道 + const health = buildReading(castByCoins([6, 6, 6, 6, 6, 6]), + { now: new Date(2026, 0, 10, 10, 30), question: '父亲的病能好起来吗' }); + const text = clashSection(health).text; + assert.match(text, /近病逢冲即愈,久病逢冲则死/, '占病那条原话该引出来'); + assert.match(text, /新病还是久病只有你清楚,这里只引这句、不替你选边/); + assert.ok(!/近病逢冲则愈/.test(text.replace('近病逢冲即愈,久病逢冲则死', '')), + '占病被替人选了「新病即愈」这一边'); + // 非占病时不摆疾病那一条 + assert.ok(!/近病逢冲即愈/.test(clashSection(plain).text), '不占病却搬了占病那条'); +}); + +test('官讼事类接得上「惟占官非、盗贼、结绝事者宜之」那半句', async () => { + // 所问既已认作官讼是非,原书末了那半句说的正是这一类,可以直接接 + const dispute = buildReading(castByCoins([7, 6, 6, 6, 6, 6]), + { now: new Date(2026, 0, 10, 10, 30), question: '这场官司能了结吗' }); + assert.equal(dispute.topic.key, 'dispute', '所问没认成官讼是非,用例选错了'); + assert.match(clashSection(dispute).text, /惟占官非、盗贼、结绝事者宜之/); + assert.match(clashSection(dispute).text, /所问正落在官讼是非上,末了那半句说的就是这一类/); + // 不在这一类上时明说不在,不替它改判吉凶 + const other = buildReading(castByCoins([7, 6, 6, 6, 6, 6]), + { now: new Date(2026, 0, 10, 10, 30), question: '我该不该换工作' }); + assert.match(clashSection(other).text, /所问不在此,断语不替它改判吉凶/); +}); + +test('卦体给六冲/六合挂一枚小标,淡字不上朱砂;右栏另有一格', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const start = client.indexOf('function clashTag('); + const tag = client.slice(start, client.indexOf('\n function ', start + 10)); + assert.ok(tag.length > 0, '没找到 clashTag 函数体'); + assert.ok(/reading\.clash/.test(tag), '小标没读卦体冲合那一组数据'); + // 本卦与变卦各判一次:本卦看它自己,变卦看变出来的那个是不是六冲/六合 + assert.ok(/which === '本卦'/.test(tag) && /if \(c\.chong\) return '六冲'/.test(tag) + && /if \(c\.changedChong\) return '六冲'/.test(tag), '小标没把本卦与变卦分开判'); + // 六冲六合是整卦的结构,不是吉凶,所以不列进朱砂名单 + assert.ok(!/' po'/.test(tag), '卦体冲合那枚小标不该用朱砂'); + // 钉在真正画出去的那一行:函数算对了、调用点不把 tag 传进去,卦面上照样什么都没有 + const block = client.slice(client.indexOf('function guaBlock(')); + const head = block.slice(0, block.indexOf('block.append(title)')); + assert.ok(/\(tag \? `\$\{tag\}<\/span>/.test(head), '卦体冲合那枚小标没把 tag 的字写进去'); + // 钉在两个调用点上:函数算对了、调用点不把 tag 传进去,卦面上照样什么都没有。 + // 只钉 clashTag 函数体的话,本卦那枚照样在,变卦那枚没了测试也不会红。 + assert.match(client, /clashTag\(reading, '本卦'\)/, '本卦那个卦体没去问要不要挂小标'); + assert.match(client, /clashTag\(reading, '变卦'\)/, '变卦那个卦体没去问要不要挂小标'); + const rule = client.slice(client.indexOf('.gua-title .gua-tag {')); + const css = rule.slice(0, rule.indexOf('}')); + assert.ok(/var\(--text-subtle\)/.test(css), '卦体冲合那枚小标该用淡字'); + assert.ok(!/var\(--seal\)/.test(css), '卦体冲合那枚小标不该染朱砂'); + // 右栏那格 + assert.ok(/\['卦体冲合', clashFact\(reading\)\]/.test(client), '右栏没有卦体冲合一格'); + const fact = client.slice(client.indexOf('function clashFact(')); + const body = fact.slice(0, fact.indexOf('\n function ')); + // 卦内零散爻与爻冲六十四卦里有三十卦都有,只为它开一格,右栏就成了流水账 + assert.ok(!/c\.incidental/.test(body), 'clashFact 不该把零散爻与爻冲单独拎出来开格'); + assert.ok(/if \(!\(c\.chong \|\| c\.he \|\| c\.changedChong \|\| c\.transformClash\.length\)\) return null;/.test(body), + '卦体既非六冲也非六合、变卦也不六冲、无动爻变冲时不该开这一格'); +}); + +test('MCP 把卦体冲合落成字段,抬头另起一行【卦体】', async () => { + const { handleMcpRequest } = await import('../miniapp/node/mcp/divination-http.mjs'); + const call = async (upper, lower) => { + let raw = ''; + const response = { writeHead() { return this; }, end(chunk) { raw += chunk; return this; } }; + await handleMcpRequest({ + response, + body: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'divination_cast', arguments: { question: '我该不该换工作', method: 'numbers', upper, lower } } }, + }); + return JSON.parse(raw); + }; + const headerOf = (text) => text.split('\n').find((line) => line.startsWith('【卦体】')) ?? null; + + // 卦体定性是整卦的,数字起卦的动爻位也不随日期动——这三组四个不同月建下取值不变, + // 所以可以直接钉死卦例,不必像【日冲】那样扫遍六十四卦。 + const chong = await call(7, 7); + assert.equal(chong.result.structuredContent.hexagram.name, '艮为山'); + assert.equal(chong.result.structuredContent.clash.chong, true, '艮为山该判成六冲卦'); + assert.equal(chong.result.structuredContent.clash.he, false); + const chongHeader = headerOf(chong.result.content[0].text); + assert.ok(chongHeader, '六冲卦该出【卦体】那一行'); + assert.match(chongHeader, /本卦六冲卦/); + + // 变卦也是六冲卦——这正是「六合变六冲」那一路要在抬头露出来的地方 + const changedChong = await call(1, 2); + const sc = changedChong.result.structuredContent; + assert.equal(sc.hexagram.name, '天泽履'); + assert.equal(sc.clash.chong, false); + assert.equal(sc.clash.he, false, '天泽履既不是六冲也不是六合,走的是「卦变六冲」那一支'); + assert.equal(sc.clash.changedChong, true, '天泽履变乾为天,变卦该是六冲卦'); + assert.match(headerOf(changedChong.result.content[0].text), /变卦六冲/); + + // 六合变六冲要单独走它自己那半句,不能跟上面那一支混成同一句 + const heToChong = await call(3, 7); + const heSc = heToChong.result.structuredContent; + assert.equal(heSc.hexagram.name, '火山旅', '用例选错了卦'); + assert.equal(heSc.clash.he, true, '火山旅该是六合卦'); + assert.equal(heSc.clash.heToChong, true, '火山旅变艮为山,该作六合变六冲'); + assert.match(headerOf(heToChong.result.content[0].text), /变卦六冲(六合变六冲)/); + + // 又不是六冲、又不是六合、变卦也不六冲、无动爻变冲:这种「不是」不值一行 + const quiet = await call(3, 1); + const quietSc = quiet.result.structuredContent; + assert.equal(quietSc.clash.chong, false); + assert.equal(quietSc.clash.he, false); + assert.equal(quietSc.clash.changedChong, false); + assert.deepEqual(quietSc.clash.transformClash, []); + assert.equal(headerOf(quiet.result.content[0].text), null, '没东西可说却出了【卦体】那一行'); + + // pairs 里给的是爻位对,程序不必再从正文里刨 + const pairs = chong.result.structuredContent.clash.pairs; + assert.deepEqual(pairs.map((pair) => [pair.lower, pair.upper]), + [[1, 4], [2, 5], [3, 6]], '三对的爻位不对'); + for (const pair of pairs) { + assert.equal(pair.kind, '冲', '艮为山三对都该判成冲'); + } +}); + + +// ── 爻之合:六合章的前三法(卦级三法见上面的六冲一节)──────────────────────── + +test('六合章的「相合法有六」:前两法六十四卦逐个走通,后三法是卦级结构', async () => { + // 章里明写「相合法有六」,本包六法都做,但分两层:前三法落在单爻上,末三法是整卦结构。 + // 末三法(卦逢六合、六冲变六合、六合变六合)由 hexagramClash 判,这一条只钉分界不重叠。 + const { heCombineReading, jingfang, hexagramClash } = await import('../miniapp/node/jingfang.mjs'); + let kinds = 0; + for (const hexagram of HEXAGRAM_LIST) { + const clash = hexagramClash(hexagram); + if (clash.chong || clash.he) kinds += 1; + const jf = jingfang(hexagram); + // 整卦六合那一卦,三对全在初四二五三六上;合好只取其余配对,所以它一个都不许收进来。 + if (clash.he) { + const got = heCombineReading(jf, { monthBranch: 0, dayBranch: 0, movingPositions: [1, 2, 3, 4, 5, 6] }, jf); + assert.equal(got.friendly.length, 0, + `${hexagram.name}是六合卦,初四二五三六三对却报进了合好——整卦六合与合好混成两处账了`); + } + } + assert.equal(kinds, 18, '六冲十个加六合八个,不是十八卦'); +}); + +test('合起只管静爻、合绊只管动爻:同一爻不会同时落进两路', async () => { + const { heCombineReading, jingfang } = await import('../miniapp/node/jingfang.mjs'); + for (const hexagram of HEXAGRAM_LIST) { + const jf = jingfang(hexagram); + for (let day = 0; day < 12; day += 1) { + for (let mask = 0; mask < 64; mask += 1) { + const moving = []; + for (let position = 1; position <= 6; position += 1) { + if (mask & (1 << (position - 1))) moving.push(position); + } + const got = heCombineReading(jf, { monthBranch: (day + 5) % 12, dayBranch: day, movingPositions: moving }); + const isMoving = (position) => moving.includes(position); + for (const item of got.rise) { + assert.ok(!isMoving(item.line.position), `${hexagram.name}的动爻${item.line.label}落进了合起`); + } + for (const item of got.bind) { + assert.ok(isMoving(item.line.position), `${hexagram.name}的静爻${item.line.label}落进了合绊`); + } + } + } + } +}); + +test('「但有一爻不动,亦不为合」:合好要两爻皆动,静动相合不算', async () => { + // 这句限定是六合章的明文,也是合好与「凡两支相合就报」的分界。 + // 做法是找一对真的相合、且不是初四二五三六的爻位,让两爻都动、再只动一个, + // 两次结果必须一次出、一次不出——只出不出都不行,那说明判据根本不是动静。 + const { heCombineReading, jingfang } = await import('../miniapp/node/jingfang.mjs'); + const { SIX_HARMONY } = await import('../miniapp/node/almanac.mjs'); + const harmonyOf = (branch) => { + for (const [x, y] of SIX_HARMONY) { + if (x === branch) return y; + if (y === branch) return x; + } + return -1; + }; + // 雷火丰六支卯丑亥午申戌,初爻卯与上爻戌相合——就这一对,且不在初四二五三六上。 + // 挑它是因为干净:一动一静两个结果一比,就看得出判据是不是「两爻皆动」。 + const feng = HEXAGRAM_LIST.find((h) => h.name === '雷火丰'); + assert.ok(feng, '缺雷火丰'); + const jf = jingfang(feng); + const both = heCombineReading(jf, { monthBranch: 0, dayBranch: 0, movingPositions: [1, 6] }); + const onlyOne = heCombineReading(jf, { monthBranch: 0, dayBranch: 0, movingPositions: [1] }); + const onlyOther = heCombineReading(jf, { monthBranch: 0, dayBranch: 0, movingPositions: [6] }); + assert.ok(both.friendly.length > 0, '初爻与上爻都动,却没报出合好'); + assert.equal(onlyOne.friendly.length, 0, '只动初爻也算合好,「但有一爻不动亦不为合」没守住'); + assert.equal(onlyOther.friendly.length, 0, '只动上爻也算合好,静动相合本不该算'); + // 六十四卦里真有相合对、且不在初四二五三六上的,一共二十卦。少了它们合好这一路 + // 就永远空转,「静动不算」也就无从对照,所以把数目钉在这里。 + let withPair = 0; + for (const hexagram of HEXAGRAM_LIST) { + const one = jingfang(hexagram); + let found = false; + for (let a = 1; a <= 6 && !found; a += 1) { + for (let b = a + 1; b <= 6; b += 1) { + if (['14', '25', '36'].includes(`${a}${b}`)) continue; + if (harmonyOf(one.lines[a - 1].branchIndex) === one.lines[b - 1].branchIndex) found = true; + } + } + if (found) withPair += 1; + } + assert.equal(withPair, 20, '有非标准相合对的卦不是二十个'); +}); + +test('化扶要动爻化出之爻回头相合,本爻不是动爻就不算', async () => { + const { heCombineReading, jingfang } = await import('../miniapp/node/jingfang.mjs'); + const { hexagramByKey } = await import('../miniapp/node/hexagrams.mjs'); + const { SIX_HARMONY } = await import('../miniapp/node/almanac.mjs'); + const partner = (branch) => { + for (const [x, y] of SIX_HARMONY) { + if (x === branch) return y; + if (y === branch) return x; + } + return -1; + }; + // 变卦照实现同一条路造:把动爻那一爻的阴阳反转,别自己另立一套判法。 + const changedOf = (key, movingPositions) => { + const lines = key.split('').map(Number); + for (const position of movingPositions) lines[position - 1] = lines[position - 1] ? 0 : 1; + return hexagramByKey(lines.join('')); + }; + let made = 0; + for (const hexagram of HEXAGRAM_LIST) { + const jf = jingfang(hexagram); + for (let moving = 1; moving <= 6; moving += 1) { + const changedJf = jingfang(changedOf(hexagram.key, [moving])); + const got = heCombineReading(jf, { monthBranch: 0, dayBranch: 0, movingPositions: [moving] }, changedJf); + for (const item of got.support) { + made += 1; + assert.equal(item.line.position, moving, `${hexagram.name}的化扶落在${item.line.label},动爻却是${moving}爻`); + assert.equal(item.changedLine.branchIndex, partner(item.line.branchIndex), + `${item.line.branch}化出${item.changedLine.branch},两支并不相合,却报了化扶`); + } + } + } + assert.ok(made > 0, '六十四卦六个爻位扫下来一个化扶都没有,化扶这一路根本没在跑'); + // 换掉一段日辰月建之后这一路照样成立——它不拿日月说话,只看化出的那一爻 + const before = made; + assert.ok(before > 0); +}); + +test('日月同支时只算一路,不把同一件事数两遍', async () => { + // 六合是十二支上的两两配对,一支的合支唯一。日辰与月建既同一支,合上它的判据 + // 就是同一条,报两次是同一条事实数了两遍。寅月寅日、申月申日都是这一路。 + const { heCombineReading, jingfang } = await import('../miniapp/node/jingfang.mjs'); + const { BRANCHES } = await import('../miniapp/node/almanac.mjs'); + const hexagram = HEXAGRAM_LIST.find((h) => h.name === '乾为天'); + const jf = jingfang(hexagram); + for (let branch = 0; branch < 12; branch += 1) { + const same = heCombineReading(jf, { monthBranch: branch, dayBranch: branch, movingPositions: [] }); + const hits = same.rise.length + same.bind.length; + assert.ok(hits <= 1, `日支月支都是${BRANCHES[branch]},却报了${hits}路`); + // 同一支换成日月各一,报的条数不该比同支时多出一份「日辰 + 月建」的重复 + const split = heCombineReading(jf, { monthBranch: (branch + 1) % 12, dayBranch: branch, movingPositions: [] }); + const splitHits = split.rise.length + split.bind.length; + assert.ok(splitHits <= same.rise.length + same.bind.length + 1, + `日支${BRANCHES[branch]}、月支${BRANCHES[(branch + 1) % 12]}报了${splitHits}路,多出来的不是同支那一路`); + } +}); + +test('断语「逢合 · 合起合绊合好化扶」四名各有一句,且不由合断吉凶', async () => { + // 这一段最容易出的错是把合当成吉。原章三处收口:「然必用神有气相宜,用若失陷无益」、 + // 「用神受克,六合有何益哉」、末了「宜合吉,不宜合凶」。所以四名照说,吉凶一句不许自己加。 + // + // 取样两头都要变:摇法只出坤为地的话(6 与 8 同为阴),卦只有一个,日支还得逐日走。 + // buildReading 的第二个参数是 { now },不是 { year, month, day }——写成后者不报错, + // 只是被整个忽略,于是日支永远停在起卦那一刻,四名里有几路一卦也碰不上。 + const seen = { rise: false, bind: false, friendly: false, support: false }; + let sawAny = false; + outer: for (let day = 0; day < 60; day += 1) { + for (let mask = 0; mask < 64; mask += 1) { + const coins = [8, 8, 8, 8, 8, 8]; + for (let i = 0; i < 6; i += 1) coins[i] = mask & (1 << i) ? 6 : 7; + const reading = buildReading(castByCoins(coins), { now: new Date(2026, 5, 1 + day, 7, 0, 0) }); + const section = reading.insights.find((item) => item.title === '逢合 · 合起合绊合好化扶'); + if (!section) continue; + sawAny = true; + const text = section.text; + // 断语开头那一句把四名逐字引了一遍,所以光查「有没有出现过这个名字」不算数—— + // 开头那句会把四条断言全顶住。改查各路自己那一句独有的措辞: + // 把合绊那一路的「皆为合绊」改成「皆为合起」,只有这一句会跟着变。 + const marker = { + rise: '皆为合起——', + bind: '皆为合绊——', + friendly: '两动爻相合为合好', + support: '为化扶——', + }; + for (const name of ['rise', 'bind', 'friendly', 'support']) { + if (reading.combine[name].length === 0) continue; + seen[name] = true; + assert.ok(text.includes(marker[name]), `报了${name},断语里却没有「${marker[name]}」那一句`); + } + // 由合断吉凶的话,一律不许出现 + assert.ok(!/诸事必成|必成|定成|准能成/.test(text), '断语替合断成了必成'); + assert.ok(text.includes('宜合吉,不宜合凶'), '原章收口那一句没照录'); + assert.ok(text.includes('用若失陷无益'), '原章「用若失陷无益」那半句没照录'); + if (Object.values(seen).every(Boolean)) break outer; + } + } + assert.ok(sawAny, '扫了这么多卦,一个逢合段都没出'); + assert.ok(Object.values(seen).every(Boolean), + `四名没凑齐,缺:${Object.entries(seen).filter(([, v]) => !v).map(([k]) => k).join('、')}`); +}); + +test('卦体给逢合的爻挂「合」小标,MCP 另给 combine 字段与【逢合】抬头', async () => { + const client = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const mcp = await readFile(new URL('../miniapp/node/mcp/divination-http.mjs', import.meta.url), 'utf8'); + const { callDivinationHttp, startDivinationServer } = await import('../miniapp/node/mcp/divination-http.mjs'); + // 卦体小标:与空破墓暗日破冲散同一层,标在逢合那一爻上 + assert.ok(/st\.combined \? '合' : ''/.test(client), '卦体没有给逢合的爻挂「合」小标'); + // 合不上朱砂:原章「宜合吉,不宜合凶」,颜色不该替没定吉凶的东西表态。 + // 这里不是只翻基础那一条 .st 规则就算数——日后有人新加一条 .st.he 也染朱砂, + // 合照样变红而基础规则一个字没动。所以把凡是提到 .st 的规则全收一遍, + // 凡带朱砂的,选择器里只许出现 po 与 tomb 这两个已定的颜色。 + const stRules = [...client.matchAll(/^\s*([^\n{]*\.st[^\n{]*)\{([\s\S]*?)\}/gm)]; + assert.ok(stRules.length >= 2, `只收到 ${stRules.length} 条 .st 规则,扫漏了`); + for (const [, selector, body] of stRules) { + if (!/var\(--seal\)/.test(body)) continue; + for (const cls of [...selector.matchAll(/\.st\.([a-z-]+)/g)].map((m) => m[1])) { + assert.ok(cls === 'po' || cls === 'tomb', `小标 .st.${cls} 染上了朱砂`); + } + } + // 上一条只查样式表。真正把它变红的是另一头:把「合」并进 po 那一档,样式表一个字都不用动。 + // 所以这里从画小标那行本身查:分 po 与 tomb 的那个条件里不许出现「合」。 + const markLine = /\.map\(\(word\) => ` barOuter, + `标签近沿 ${labelNear.toFixed(1)} 没让开爻杠外沿 ${barOuter.toFixed(1)},爻杠会压到字`); + + const viewBox = /class="qiring" viewBox="(-?[0-9.]+) (-?[0-9.]+) ([0-9.]+) ([0-9.]+)"/.exec(html); + assert.ok(viewBox, '消长环的 viewBox 没找到'); + const [minX, minY, w, h] = viewBox.slice(1).map(Number); + assert.equal(minX, 100 - w / 2, '画布没有以圆心 (100,100) 对称展开'); + assert.equal(minY, 100 - h / 2, '画布没有以圆心 (100,100) 对称展开'); + // 最外那两个字:标签远沿 = R + 偏移 + 一整行字高,必须留在画布里。 + const labelFar = R + Number(labelR[1]) + 11; + assert.ok(labelFar <= w / 2, + `最外的字伸到半径 ${labelFar.toFixed(1)},超出画布半径 ${(w / 2).toFixed(1)},会被裁掉`); +}); + +/* ---------- 进程边界:日志不记路径、Host/Origin 只认回环 ---------- */ + +test('Host 头取主机名:去端口,IPv6 认方括号', () => { + assert.equal(hostnameFromHeader('127.0.0.1:41999'), '127.0.0.1'); + assert.equal(hostnameFromHeader('localhost:8080'), 'localhost'); + assert.equal(hostnameFromHeader('127.0.0.1'), '127.0.0.1', '不带端口时整串就是主机名'); + // ::1 不带方括号时,一串冒号会被按最后一个切开,切出的是 "1" 不是 "::1" + assert.equal(hostnameFromHeader('[::1]:41999'), '::1'); + assert.equal(hostnameFromHeader('[::1]'), '::1'); + // 大小写不在这一层归一,hostnameFromHeader 只管切。归一在 isLoopbackHostname 里, + // 两边都走它,才不会有的入口归一、有的不归一。 + assert.equal(hostnameFromHeader('EVIL.example.COM:80'), 'EVIL.example.COM', '这一层只切不归一'); + assert.equal(isLoopbackHostname(hostnameFromHeader('LOCALHOST:41999')), true, '归一之后 localhost 仍算回环'); +}); + +test('回环主机名:127/8、localhost、::1 算,别的都不算', () => { + // 这一层收的是「主机名」不是「Host 头」——带端口的串归 isLocalRequest 切完再送进来。 + // 所以这里不喂 '127.0.0.1:1':那不是主机名,判 false 是对的,喂进去只会把 + // 「这一层不负责切端口」和「这一层不认回环」两件事搅在一起。 + for (const ok of ['127.0.0.1', '127.1.2.3', 'localhost', '::1', '0:0:0:0:0:0:0:1', 'LOCALHOST', 'LocalHost']) { + assert.equal(isLoopbackHostname(ok), true, `${ok} 应当算回环`); + } + for (const bad of ['evil.example.com', '10.0.0.1', '192.168.1.5', '0.0.0.0', '128.0.0.1', '127.0.0.256', '127.0.0.01', '127.0.0', '127.0.0.1.1', '127.0.0.1:1', '']) { + assert.equal(isLoopbackHostname(bad), false, `${bad} 不该算回环`); + } +}); + +test('本机判定:Host 拦 DNS rebinding,Origin 拦跨源简单请求', () => { + // 正常:页面自己发的同源请求 + assert.equal(isLocalRequest('127.0.0.1:41999', 'http://127.0.0.1:41999'), true, '同源请求该放行'); + // 端口由 Host 分配,判定不能把它算进主机名,否则回环请求会被自己挡在门外 + for (const host of ['127.0.0.1:41999', '127.0.0.1', 'localhost:80', '[::1]:41999', '127.5.5.5:1']) { + assert.equal(isLocalRequest(host, undefined), true, `回环 Host ${host} 应当放行`); + } + // Host 的 MCP 客户端是 Node 程序,不发 Origin + assert.equal(isLocalRequest('127.0.0.1:41999', undefined), true, '缺 Origin 不该当成伪造'); + // HTTP/1.0 可能不带 Host + assert.equal(isLocalRequest(undefined, undefined), true, '缺 Host 不该当成伪造'); + // DNS rebinding:攻击者域名解析到 127.0.0.1,Host 带的是他的域名 + assert.equal(isLocalRequest('evil.example.com', undefined), false, '非回环 Host 必须挡住'); + assert.equal(isLocalRequest('evil.example.com:41999', 'http://evil.example.com'), false, '带端口的非回环 Host 同样要挡'); + // 跨源简单请求:请求直接打到 127.0.0.1,Host 是合法的,挡它的只有 Origin + assert.equal(isLocalRequest('127.0.0.1:41999', 'https://evil.example.com'), false, '跨源 Origin 必须挡住'); + // 挡不住的那一类要说清楚:攻击者拿不到 Origin 头由别人代发 + assert.equal(isLocalRequest('127.0.0.1:41999', 'not-a-url'), false, 'Origin 解析不出来时按拒绝处理'); +}); + +test('日志只留错误码:Node 的 fs 报错带绝对路径,describeError 把它摘掉', async () => { + const dir = await mkdtemp(join(tmpdir(), 'divination-log-')); + try { + const real = await readFile(join(dir, 'absent.json'), 'utf8').then(() => null, (error) => error); + // 取证前提:先确认 Node 真的把绝对路径写进了 message。不认这一条, + // 下面那句「摘掉了路径」就是空断言——碰巧 Node 哪天不带了,测试照样绿。 + assert.ok( + real instanceof Error && real.message.includes(dir), + `当前 Node ${process.version} 的 fs 报错已不带绝对路径,本测试的前提需重估:${real && real.message}`, + ); + const described = describeError(real); + assert.equal(described, 'ENOENT', `只剩错误码才对,实到:${described}`); + assert.ok(!described.includes(dir), `describeError 仍带绝对路径:${described}`); + assert.ok(!described.includes('/'), `describeError 里不该有路径分隔符:${described}`); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('describeError 对没有 code 的错误退回 name,对非错误退回 unknown', () => { + assert.equal(describeError(new TypeError('x')), 'TypeError', '无 code 时用 name'); + assert.equal(describeError('一段字符串'), 'unknown', '非 Error 不该被当成有诊断信息'); + assert.equal(describeError(undefined), 'unknown', 'undefined 也一样'); +}); + +/** 起一个真服务,返回打请求的函数与 dispose。Host 头要用 node:http 直发,fetch 不让设。 */ +async function withServer(run) { + const dir = await mkdtemp(join(tmpdir(), 'divination-http-')); + const logs = []; + const port = 42871; + const lifecycle = await start({ + pluginRoot: fileURLToPath(new URL('..', import.meta.url)), + dataDir: dir, + listen: { host: '127.0.0.1', port }, + logger: { + info: (m) => logs.push(m), + warn: (m) => logs.push(m), + error: (m) => logs.push(m), + debug: (m) => logs.push(m), + }, + signal: new AbortController().signal, + }); + const send = (path, headers = {}) => new Promise((resolve, reject) => { + const req = httpRequest({ host: '127.0.0.1', port, path, method: 'GET', headers }, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => resolve({ status: res.statusCode, body })); + }); + req.on('error', reject); + req.end(); + }); + try { + await run({ send, logs, dir }); + } finally { + await lifecycle.dispose(); + await rm(dir, { recursive: true, force: true }); + } +} + +test('非回环 Host 的请求整个被拒,且不回显自己是什么', async () => { + await withServer(async ({ send }) => { + const good = await send('/api/divination/bootstrap', { host: '127.0.0.1:42871' }); + assert.equal(good.status, 200, '回环 Host 应当照常放行,否则页面会整个打不开'); + + const evil = await send('/api/divination/history', { host: 'evil.example.com' }); + assert.equal(evil.status, 403, `DNS rebinding 的读卦历请求该被拒,实到 ${evil.status}`); + assert.ok(!evil.body.includes('evil.example.com'), `403 回包不该回显 Host:${evil.body}`); + + const crossOrigin = await send('/api/divination/history', { + host: '127.0.0.1:42871', + origin: 'https://evil.example.com', + }); + assert.equal(crossOrigin.status, 403, '跨源 Origin 应当被拒'); + }); +}); + +test('MCP 端点同样只认回环,Node 客户端不带 Origin 照样能用', async () => { + await withServer(async ({ send }) => { + const payload = JSON.stringify({ + jsonrpc: '2.0', id: 1, method: 'tools/call', + params: { name: 'divination_almanac', arguments: {} }, + }); + const ok = await new Promise((resolve, reject) => { + const req = httpRequest({ + host: '127.0.0.1', port: 42871, path: '/mcp/divination', method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }, + }, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (c) => { body += c; }); + res.on('end', () => resolve({ status: res.statusCode, body })); + }); + req.on('error', reject); + req.end(payload); + }); + assert.equal(ok.status, 200, `不带 Origin 的 MCP 调用该照常работать,实到 ${ok.status}`); + assert.ok(ok.body.includes('干支'), 'MCP 应当照常返回历法'); + + const evil = await new Promise((resolve, reject) => { + const req = httpRequest({ + host: '127.0.0.1', port: 42871, path: '/mcp/divination', method: 'POST', + headers: { 'content-type': 'application/json', host: 'evil.example.com' }, + }, (res) => { res.resume(); res.on('end', () => resolve({ status: res.statusCode })); }); + req.on('error', reject); + req.end(payload); + }); + assert.equal(evil.status, 403, 'MCP 端点也得挡非回环 Host'); + }); +}); + +test('落盘失败时日志里没有 dataDir 路径', async () => { + await withServer(async ({ send, logs, dir }) => { + // 把 readings.json 换成目录,逼出 fs 报错(EISDIR/ENOENT 之外的真实分支) + await mkdir(join(dir, 'readings.json'), { recursive: true }); + const res = await send('/api/divination/history'); + assert.equal(res.status, 500, '读失败应当是 500'); + assert.equal(res.body, '{"error":"internal_error"}', `回包不该带内部细节:${res.body}`); + const failures = logs.filter((line) => line.startsWith('divination.request.failed')); + assert.ok(failures.length > 0, '这次失败应当被记进日志'); + for (const line of failures) { + assert.ok(!line.includes(dir), `日志带出了 dataDir 绝对路径:${line}`); + assert.ok(!line.includes(tmpdir()), `日志带出了系统临时目录:${line}`); + } + }); +});