From 67c641db01207d32a08d2d265a2175b181671c87 Mon Sep 17 00:00:00 2001 From: ijwerer Date: Wed, 15 Jul 2026 11:19:49 +0800 Subject: [PATCH] Add expected draws visualization --- docs/tile-analysis-visualization-report.md | 396 +++++++++++++++++++++ frontend/src/components/AddTileMenu.tsx | 1 + frontend/src/i18n/resources/en.json | 13 + frontend/src/i18n/resources/ja.json | 13 + frontend/src/i18n/resources/zh-CN.json | 13 + frontend/src/i18n/resources/zh-TW.json | 13 + frontend/src/tiles/ExpectedDrawsTile.tsx | 182 ++++++++++ frontend/src/tiles/defaults.ts | 9 + frontend/src/tiles/registry.tsx | 2 + frontend/src/types.ts | 2 + src/analysis/improves.rs | 6 + src/analysis/result.rs | 4 + src/analysis/score.rs | 27 +- 13 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 docs/tile-analysis-visualization-report.md create mode 100644 frontend/src/tiles/ExpectedDrawsTile.tsx diff --git a/docs/tile-analysis-visualization-report.md b/docs/tile-analysis-visualization-report.md new file mode 100644 index 00000000..a5868906 --- /dev/null +++ b/docs/tile-analysis-visualization-report.md @@ -0,0 +1,396 @@ +# 牌型分析可视化模块工作报告 + +## 一、调研范围 + +本次遍历范围集中在实时牌型分析链路和前端可视化入口: + +- 后端分析输出:`src/analysis/`,重点为 `result.rs`、`mod.rs`、`improves.rs`、`search.rs`、`risk/`。 +- IPC 与前端状态:`frontend/src/types.ts`、`frontend/src/hooks/useTauriBridge.ts`、`frontend/src/stores/analysisStore.ts`。 +- 实时可视化磁贴:`frontend/src/tiles/`,重点为 `SelfHandTile.tsx`、`RecommendationsTile.tsx`、`RiskChartTile.tsx`、`OpponentsTile.tsx`、`registry.tsx`、`defaults.ts`。 +- 牌面渲染基础设施:`frontend/src/components/Mahgen.tsx`、`frontend/src/lib/tileIdx.ts`。 + +结论:当前后端已经产出较完整的牌型分析数据,前端实时可视化只消费了其中一部分。新增可视化模块可以沿用现有的 `TileFrame + useAnalysisStore + Mahgen / 表格 / 轻量条形图` 模式,不必改变后端分析事件管道。 + +## 二、现有分析数据结构 + +后端顶层入口是 `analysis::analyze(info)`。它按当前手牌张数分流: + +- 13 张等待状态:填充 `hand13`,用于展示向听、听牌、改良、和牌率、点数期望等。 +- 14 张打牌状态:填充 `hand14`,枚举切牌候选;每个候选内嵌一次切牌后的 `Hand13Result`。 +- 两种状态都会计算对手风险、综合风险、最佳进攻切牌、最佳防守切牌。 + +关键字段来自 `AnalysisResult`: + +- `shanten`:当前向听数。 +- `state`:`wait13` 或 `discard14`。 +- `hand13.waits`:有效进张列表,每项包含牌、剩余枚数、和牌率。 +- `hand13.next_shanten_waits_count`:摸到有效牌之后,再做最佳切牌可得到的下一层听牌枚数。 +- `hand13.mixed_waits_score`:速度评分,综合当前进张和下一层进张。 +- `hand13.improves`:非直接进张但能扩大牌型的摸牌改良。 +- `hand13.avg_agari_rate`、`dama_point`、`riichi_point`、`mixed_round_point`:和牌率与点数期望。 +- `hand14.maintain`:不退向的切牌候选,已按速度评分、进张枚数、和牌率排序。 +- `hand14.backwards`:退向候选,当前保留但未展示。 +- `opponents[].risk` 与 `mixed_risk`:34 种牌的逐牌放铳风险。 +- `best_attack_discard`、`best_defence_discard`:进攻与防守倾向的推荐切牌。 + +这说明当前可视化模块的扩展瓶颈主要在前端展示,而不是后端数据缺失。 + +## 三、现有可视化模块内容 + +### 1. 自家手牌 + +`SelfHandTile` 读取 `useGameStore` 中的 `MahgenView`,取 `view.players[ourSeat].hand` 后交给 `` 渲染。该模块不直接消费分析结果,但它是牌型分析视图的基础参照。 + +实现特征: + +- 使用 `TileFrame` 统一外壳。 +- 使用 `` 显示手牌。 +- 后端已经提前将手牌转换成 mahgen DSL,前端只负责渲染。 + +### 2. 推荐切牌 + +`RecommendationsTile` 读取 `useAnalysisStore.result`,取 `result.hand14.maintain.slice(0, 3)` 展示前三个不退向切牌候选。 + +当前展示内容: + +- 切牌牌面。 +- 切牌后的 `mixed_round_point`,标记为 EV。 +- 切牌后的 `avg_agari_rate`。 +- 切牌后的 `waits_total`。 +- 右上角展示当前向听数。 + +实现特征: + +- 单张切牌通过 `mjaiToMahgen([c.discard])` 转为 mahgen DSL。 +- 候选排序由后端 `analyze_14` 完成,前端只取前三。 +- UI 是列表卡片,不使用图表库。 + +当前不足: + +- 未展示 `best_attack_discard` 和 `best_defence_discard` 的对比。 +- 未展示候选切牌后的具体进张牌。 +- 未展示退向候选的代价,用户难以理解“为什么某些牌不推荐”。 + +### 3. 综合放铳风险 + +`RiskChartTile` 读取 `result.mixed_risk`,按 34 种牌逐行展示风险条。 + +当前展示内容: + +- 牌种标签。 +- 风险百分比。 +- 低、中、高三段颜色:小于 10 为绿色,10 到 20 为黄色,20 以上为红色。 + +实现特征: + +- 使用 `TILE_LABELS_34` 保持 34 维风险数组与牌名一致。 +- 使用 CSS 宽度条模拟横向条形图,没有引入 Recharts。 +- 数据来自混合风险,已按对手听牌率做门控和组合。 + +当前不足: + +- 只展示综合风险,不展示每个对手的风险差异。 +- 34 行列表阅读成本高,缺少按手牌可切牌过滤的视图。 +- 没有直接标注最佳防守切牌。 + +### 4. 对手概览 + +`OpponentsTile` 读取 `result.opponents`,用表格展示每个对手的听牌率、是否立直、最大风险。 + +当前展示内容: + +- 相对座位。 +- 听牌率。 +- 立直标记。 +- 该对手 34 维风险中的最大值。 + +实现特征: + +- 使用项目已有的 `Table` 组件。 +- 座位标签按 3 人/4 人场分别处理。 +- 该模块是风险模型的摘要入口。 + +当前不足: + +- 未展示某个对手最危险的具体牌。 +- 未展示各对手对同一候选切牌的风险贡献。 + +### 5. 历史图表 + +历史页已有 Recharts 实现: + +- `RankPieChart`:名次分布饼图。 +- `CumulativePtChart`:累计 PT 折线图。 + +虽然历史页不属于实时牌型分析,但它说明项目已经接受 Recharts 作为图表依赖。若新增实时分析图表需要折线、饼图、雷达图或堆叠条形图,可以复用相同依赖和主题变量。 + +## 四、现有实现方式总结 + +### 数据流 + +当前实时分析数据流如下: + +1. 后端 `analysis::runner` 在 tracker 处理事件后生成 `AnalysisResult`。 +2. 后端通过 Tauri 事件发出 `analysis-result`。 +3. 前端 `useTauriBridge` 监听该事件,并写入 `useAnalysisStore`。 +4. 各个磁贴组件通过 Zustand selector 读取 `result` 的局部字段。 +5. 牌面使用 ``,数值使用表格、列表或 CSS 条形条展示。 + +### 模块注册方式 + +新增实时可视化模块需遵循当前磁贴体系: + +1. 在 `frontend/src/tiles/defaults.ts` 增加新的 `TileId`。 +2. 将新 id 加入 `ALL_TILES`、`TILE_TITLES`、4 人和 3 人默认布局。 +3. 在 `frontend/src/tiles/registry.tsx` import 新组件,并在 `renderTile` 中补一个 case。 +4. 在 `frontend/src/components/AddTileMenu.tsx` 增加标题 i18n key 映射。 +5. 在 `frontend/src/i18n/resources/*.json` 添加标题与空态文案。 +6. 新组件内部使用 `TileFrame`,通过 `useAnalysisStore` 读取分析数据。 + +### 牌面渲染方式 + +项目使用 `` Web Component,前端封装为 `Mahgen`: + +- 后端 `MahgenView` 已可直接提供完整手牌、河牌、副露 DSL。 +- 分析结果中仍是 mjai 字符串时,前端使用 `mjaiToMahgen()` 转换。 +- 单张推荐、进张、改良摸牌都可以复用这一方式。 + +### 图表方式 + +实时分析页现有风险模块使用 CSS 条形条,历史页使用 Recharts。建议新增模块按复杂度选择: + +- 牌面、短列表、候选对比:继续使用 `TileFrame + Mahgen + CSS`。 +- 多维趋势、分布、堆叠对比:复用 Recharts,保持 `ResponsiveContainer` 和 CSS 变量配色。 + +## 五、可新增的可视化模块建议 + +### 1. 进张与和牌率模块 + +目标:补齐 13 张等待状态下的核心信息,让用户看到“现在摸什么有用”。 + +数据来源: + +- `result.hand13.waits` +- `result.hand13.waits_total` +- `result.hand13.avg_agari_rate` +- `result.hand13.is_furiten` +- `result.hand13.furiten_rate` + +展示方式: + +- 顶部显示向听、总进张枚数、平均和牌率。 +- 下方按 `left` 或 `agari_rate` 排序,逐行显示牌面、剩余枚数、单牌和牌率。 +- 听牌振听时增加醒目标记。 + +实现方式: + +- 新建 `WaitsTile.tsx`。 +- 组件读取 `const hand13 = useAnalysisStore((s) => s.result?.hand13 ?? null)`。 +- 每行使用 ``。 +- 使用与 `RecommendationsTile` 相同的列表卡片样式。 + +价值: + +- 这是当前 `Hand13Result` 已经计算但未直接展示的第一优先级数据。 +- 对用户理解牌型速度最直接。 + +### 2. 切牌候选详情模块 + +目标:把前三推荐从“结论列表”扩展为“候选解释”。 + +数据来源: + +- `result.hand14.maintain` +- 每个候选的 `discard`、`result.waits`、`waits_total`、`mixed_waits_score`、`avg_agari_rate`、`mixed_round_point` +- `result.best_attack_discard` +- `result.best_defence_discard` + +展示方式: + +- 表格或列表展示全部不退向候选。 +- 列:切牌、速度评分、总进张、和牌率、EV、主要进张。 +- 用标签标出“攻”和“守”:`best_attack_discard`、`best_defence_discard`。 + +实现方式: + +- 可新建 `DiscardCandidatesTile.tsx`,也可扩展现有 `RecommendationsTile`。 +- 仍沿用 `Mahgen + pct + toFixed` 的轻量实现。 +- 为避免空间拥挤,默认只展示前 6 个候选,其余折叠或滚动。 + +价值: + +- 能解释推荐来源,降低“AI 黑箱感”。 +- 可以让用户看到攻守分歧,比如最快切牌和最安全切牌不一致。 + +### 3. 改良摸牌模块 + +目标:展示“摸到某些牌虽然不进向,但会让牌型变宽”的信息。 + +数据来源: + +- `result.hand13.improves` +- `result.hand13.improve_way_count` +- `result.hand13.avg_improve_waits_count` + +展示方式: + +- 顶部显示改良方式数与平均改良后进张数。 +- 每行:摸牌牌面、改良后总进张、改良后的等待列表。 +- 可按 `widened_total` 降序排列。 + +实现方式: + +- 新建 `ImprovesTile.tsx`。 +- 每个 `ImproveEntry.draw` 用 `mjaiToMahgen([draw])` 渲染。 +- `widened_waits` 可显示前若干张牌,鼠标悬停或展开时显示完整列表。 + +价值: + +- 这部分是后端 13 张分析中相当有价值但完全未展示的数据。 +- 对一向听、两向听的牌效理解尤其有帮助。 + +### 4. 下一向听速度热力模块 + +目标:把 `next_shanten_waits_count` 从数字映射变成 34 牌种热力图。 + +数据来源: + +- `result.hand13.next_shanten_waits_count` +- `result.hand13.avg_next_shanten_waits` +- `TILE_LABELS_34` + +展示方式: + +- 34 格热力图,每格对应一种牌。 +- 数值越高,背景越强;空值或 0 弱化。 +- 顶部显示平均下一层进张数。 + +实现方式: + +- 新建 `NextShantenHeatmapTile.tsx`。 +- 可沿用 `RiskChartTile` 的 34 维数组遍历方式,但改成网格。 +- 若要显示牌面,可先用文本标签;后续再改为小尺寸 `Mahgen`。 + +价值: + +- 它能说明当前进张后,下一步牌型是否仍然宽。 +- 与 `mixed_waits_score` 的解释关系最强。 + +### 5. 攻守对照模块 + +目标:将进攻推荐与防守推荐并排,辅助用户在高风险场景下选择。 + +数据来源: + +- `result.best_attack_discard` +- `result.best_defence_discard` +- `result.mixed_risk` +- `result.hand14.maintain` +- `tileIdx()` + +展示方式: + +- 两个小面板:进攻切牌、防守切牌。 +- 显示对应牌、风险值、切后进张/EV。 +- 若两者相同,显示“攻守一致”;若不同,显示差异。 + +实现方式: + +- 新建 `AttackDefenceTile.tsx`。 +- 使用 `tileIdx(discard)` 从 `mixed_risk` 中取风险。 +- 用 `hand14.maintain.find(c => c.discard === discard)` 补充进攻指标。 + +价值: + +- 当前后端已经给出两个结论,但前端没有明确可视化。 +- 实战中比单纯前三推荐更符合“局势判断”的需要。 + +### 6. 单对手风险矩阵模块 + +目标:把综合风险拆解到每个对手,帮助用户判断风险来源。 + +数据来源: + +- `result.opponents[].risk` +- `result.opponents[].tenpai_rate` +- `result.opponents[].is_riichi` +- `TILE_LABELS_34` + +展示方式: + +- 行为牌种,列为对手,单元格显示风险或颜色。 +- 也可只显示“当前手牌可切牌”的风险,降低密度。 + +实现方式: + +- 新建 `OpponentRiskMatrixTile.tsx`。 +- 若使用完整 34 x 3/4 矩阵,建议 CSS grid;若只展示候选切牌,可以复用 `Table`。 + +价值: + +- 弥补 `RiskChartTile` 只看综合风险的不足。 +- 对立直者、鸣牌者的风险差异更直观。 + +## 六、优先级建议 + +建议按以下顺序实现: + +1. `WaitsTile`:数据稳定、实现最简单、价值最高。 +2. `DiscardCandidatesTile`:直接增强现有推荐切牌解释力。 +3. `AttackDefenceTile`:利用已有最佳攻守字段,实战决策价值高。 +4. `ImprovesTile`:展示牌效深度,适合进阶用户。 +5. `NextShantenHeatmapTile`:视觉效果强,但需要更仔细地设计密度。 +6. `OpponentRiskMatrixTile`:信息量大,适合作为高级/可隐藏模块。 + +## 七、同实现方式的接入模板 + +新增模块推荐保持以下结构: + +```tsx +import { useTranslation } from 'react-i18next' +import { TileFrame } from '@/components/TileFrame' +import { Mahgen } from '@/components/Mahgen' +import { useAnalysisStore } from '@/stores/analysisStore' +import { mjaiToMahgen } from '@/lib/tileIdx' +import type { Breakpoint } from '@/tiles/defaults' + +export function ExampleAnalysisTile({ bp }: { bp: Breakpoint }) { + const { t } = useTranslation() + const result = useAnalysisStore((s) => s.result) + + return ( + + {!result ? ( + {t('tile.analysis_empty')} + ) : ( + + )} + + ) +} +``` + +接入清单: + +- `frontend/src/tiles/defaults.ts`:扩展 `TileId`、`ALL_TILES`、默认布局、标题。 +- `frontend/src/tiles/registry.tsx`:注册新组件。 +- `frontend/src/components/AddTileMenu.tsx`:添加 i18n key 映射。 +- `frontend/src/i18n/resources/en.json`、`zh-CN.json`、`zh-TW.json`、`ja.json`:添加标题与空态文案。 +- 如需牌面:使用 `Mahgen` 与 `mjaiToMahgen`。 +- 如需百分比:使用 `pct()`。 +- 如需 34 维牌种:复用 `TILE_LABELS_34` 与 `tileIdx()`。 + +## 八、风险与注意事项 + +- Zustand selector 不应每次返回新数组;需要像 `OpponentsTile` 一样使用稳定空数组,避免 React 重渲染循环。 +- 34 维数据在小尺寸磁贴内会很挤,建议默认只展示关键项,完整视图放在可滚动区域。 +- `hand13` 与 `hand14` 互斥,组件必须按 `result.state` 或 null 值处理空态。 +- `agari_rate` 在非听牌状态可能为 `null` 或无意义,显示时需用 `pct()` 兜底。 +- `mixed_risk` 是按 34 维 Tile34 索引排列,必须通过 `tileIdx()` 或 `TILE_LABELS_34` 对齐,不能按字符串排序后直接索引。 +- 若新增 Recharts 图表,应沿用历史页的 `ResponsiveContainer`、CSS 变量配色和 tooltip 样式,保证主题一致。 + +## 九、总体结论 + +当前 Akagi 的牌型分析后端已经具备较完整的数据面:进张、改良、切牌候选、速度评分、和牌率、点数期望、对手风险和综合风险都已通过 `AnalysisResult` 下发。前端实时可视化目前只覆盖了手牌、前三推荐、综合风险、对手摘要四类信息。 + +后续新增模块不需要重做分析管道,应沿用现有磁贴体系:`TileFrame` 负责统一容器和布局控制,`useAnalysisStore` 负责消费实时分析结果,`Mahgen` 负责牌面渲染,表格/CSS 条形条/Recharts 负责数值可视化。最值得优先补齐的是进张列表、切牌候选详情、攻守对照和改良摸牌四个模块。 diff --git a/frontend/src/components/AddTileMenu.tsx b/frontend/src/components/AddTileMenu.tsx index 6f848fd3..11deb23d 100644 --- a/frontend/src/components/AddTileMenu.tsx +++ b/frontend/src/components/AddTileMenu.tsx @@ -27,6 +27,7 @@ const TILE_TITLE_KEYS: Record = { 'self-hand': 'tile.self_hand', 'board': 'tile.board', 'recommendations': 'tile.recommendations', + 'expected-draws': 'tile.expected_draws', 'risk-chart': 'tile.risk_chart', 'opponents': 'tile.opponents', 'events': 'tile.events', diff --git a/frontend/src/i18n/resources/en.json b/frontend/src/i18n/resources/en.json index 2edd5056..ada4620d 100644 --- a/frontend/src/i18n/resources/en.json +++ b/frontend/src/i18n/resources/en.json @@ -176,6 +176,19 @@ "board_empty": "No game yet — connect a game.", "recommendations": "Algorithmic Top 3 Discards", "recommendations_empty": "Awaiting analysis.", + "expected_draws": "Expected Draws", + "expected_draws_empty": "Awaiting discard analysis.", + "expected_draws_ai": "AI", + "expected_draws_after_discard": "Cut", + "expected_draws_current": "Current hand", + "expected_draws_waits": "Draws", + "expected_draws_agari": "Win %", + "expected_draws_score": "Points", + "expected_draws_ev": "EV", + "expected_draws_dama": "Dama ", + "expected_draws_riichi": "Riichi ", + "expected_draws_left_unit": " tiles", + "expected_draws_no_yaku": "No confirmed yaku", "risk_chart": "Mixed Deal-in Risk", "risk_chart_empty": "Awaiting analysis.", "opponents": "Opponents", diff --git a/frontend/src/i18n/resources/ja.json b/frontend/src/i18n/resources/ja.json index b41c84bd..9eba7ed1 100644 --- a/frontend/src/i18n/resources/ja.json +++ b/frontend/src/i18n/resources/ja.json @@ -176,6 +176,19 @@ "board_empty": "対局がまだありません — ゲームに接続してください。", "recommendations": "アルゴリズム推奨打牌 トップ3", "recommendations_empty": "解析待ちです。", + "expected_draws": "期待入牌", + "expected_draws_empty": "打牌解析待ちです。", + "expected_draws_ai": "AI", + "expected_draws_after_discard": "打", + "expected_draws_current": "現在の手牌", + "expected_draws_waits": "入牌", + "expected_draws_agari": "和了率", + "expected_draws_score": "点数", + "expected_draws_ev": "EV", + "expected_draws_dama": "黙", + "expected_draws_riichi": "立", + "expected_draws_left_unit": "枚", + "expected_draws_no_yaku": "確定役なし", "risk_chart": "総合放銃リスク", "risk_chart_empty": "解析待ちです。", "opponents": "相手", diff --git a/frontend/src/i18n/resources/zh-CN.json b/frontend/src/i18n/resources/zh-CN.json index 48b365af..009b87fb 100644 --- a/frontend/src/i18n/resources/zh-CN.json +++ b/frontend/src/i18n/resources/zh-CN.json @@ -176,6 +176,19 @@ "board_empty": "尚无对局 — 请先连接到游戏。", "recommendations": "纯算法前三推荐切牌", "recommendations_empty": "等待分析中。", + "expected_draws": "期望入张", + "expected_draws_empty": "等待推荐打牌分析中。", + "expected_draws_ai": "AI", + "expected_draws_after_discard": "打", + "expected_draws_current": "当前手牌", + "expected_draws_waits": "入张", + "expected_draws_agari": "和率", + "expected_draws_score": "点数", + "expected_draws_ev": "EV", + "expected_draws_dama": "默", + "expected_draws_riichi": "立", + "expected_draws_left_unit": "枚", + "expected_draws_no_yaku": "暂无可确认役种", "risk_chart": "综合放铳风险", "risk_chart_empty": "等待分析中。", "opponents": "对手", diff --git a/frontend/src/i18n/resources/zh-TW.json b/frontend/src/i18n/resources/zh-TW.json index be3cc28b..fceec1de 100644 --- a/frontend/src/i18n/resources/zh-TW.json +++ b/frontend/src/i18n/resources/zh-TW.json @@ -176,6 +176,19 @@ "board_empty": "尚無對局 — 請先連線至遊戲。", "recommendations": "純算法前三建議切牌", "recommendations_empty": "等待分析中。", + "expected_draws": "期望入張", + "expected_draws_empty": "等待建議打牌分析中。", + "expected_draws_ai": "AI", + "expected_draws_after_discard": "打", + "expected_draws_current": "目前手牌", + "expected_draws_waits": "入張", + "expected_draws_agari": "和率", + "expected_draws_score": "點數", + "expected_draws_ev": "EV", + "expected_draws_dama": "默", + "expected_draws_riichi": "立", + "expected_draws_left_unit": "枚", + "expected_draws_no_yaku": "暫無可確認役種", "risk_chart": "綜合放銃風險", "risk_chart_empty": "等待分析中。", "opponents": "對手", diff --git a/frontend/src/tiles/ExpectedDrawsTile.tsx b/frontend/src/tiles/ExpectedDrawsTile.tsx new file mode 100644 index 00000000..561f67d7 --- /dev/null +++ b/frontend/src/tiles/ExpectedDrawsTile.tsx @@ -0,0 +1,182 @@ +import { useTranslation } from 'react-i18next' +import { TileFrame } from '@/components/TileFrame' +import { Mahgen } from '@/components/Mahgen' +import { useAnalysisStore } from '@/stores/analysisStore' +import { useNotifyStore } from '@/stores/notifyStore' +import { fmtScore, pct } from '@/lib/format' +import { mjaiToMahgen } from '@/lib/tileIdx' +import type { Hand13Result, Hand14Result } from '@/types' +import type { Breakpoint } from '@/tiles/defaults' + +type Row = { + key: string + label: string + discard: string | null + result: Hand13Result +} + +export function ExpectedDrawsTile({ bp }: { bp: Breakpoint }) { + const { t, i18n } = useTranslation() + const result = useAnalysisStore((s) => s.result) + const latestBotDiscard = useNotifyStore(latestDiscardFromResponses) + const rows = rowsFromResult( + result?.hand14, + result?.hand13, + latestBotDiscard, + t('tile.expected_draws_ai'), + ) + const shanten = result?.shanten + const preferEnglish = i18n.resolvedLanguage?.startsWith('en') || i18n.language?.startsWith('en') + + return ( + + {t('mahjong.shanten_value', { n: shanten })} + + ) + } + contentClassName="flex flex-col gap-2" + > + {rows.length === 0 ? ( + {t('tile.expected_draws_empty')} + ) : ( + rows.map((row) => ( +
+
+
+ + {row.label} + + {row.discard ? ( + <> + + {t('tile.expected_draws_after_discard')} + + + + ) : ( + {t('tile.expected_draws_current')} + )} +
+ + {t('tile.expected_draws_ev')}: {row.result.mixed_round_point.toFixed(0)} + +
+ +
+ + + +
+ + + +
+ )) + )} +
+ ) +} + +function rowsFromResult( + hand14: Hand14Result | null | undefined, + hand13: Hand13Result | null | undefined, + botDiscard: string | null, + aiLabel: string, +): Row[] { + if (hand14?.maintain.length) { + const allCandidates = [...hand14.maintain, ...hand14.backwards] + const botCandidate = botDiscard + ? allCandidates.find((candidate) => candidate.discard === botDiscard) + : null + const candidates = botCandidate + ? [ + botCandidate, + ...hand14.maintain + .filter((candidate) => candidate.discard !== botCandidate.discard) + .slice(0, 2), + ] + : hand14.maintain.slice(0, 3) + + return candidates.map((candidate, index) => ({ + key: `${candidate.discard}-${index}`, + label: botCandidate && index === 0 ? aiLabel : String(index + 1), + discard: candidate.discard, + result: candidate.result, + })) + } + if (hand13) { + return [{ key: 'current', label: '—', discard: null, result: hand13 }] + } + return [] +} + +function latestDiscardFromResponses(s: ReturnType): string | null { + for (let i = s.responses.length - 1; i >= 0; i--) { + const response = s.responses[i] + if (response.type === 'dahai') return response.pai + if (response.type === 'reach' && response.pai) return response.pai + } + return null +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function WaitList({ waits }: { waits: Hand13Result['waits'] }) { + const topWaits = [...waits] + .sort((a, b) => b.left - a.left || (b.agari_rate ?? 0) - (a.agari_rate ?? 0)) + .slice(0, 8) + + if (topWaits.length === 0) return null + + return ( +
+ {topWaits.map((wait) => ( + + + ×{wait.left} + {wait.agari_rate != null && ( + {pct(wait.agari_rate)} + )} + + ))} +
+ ) +} + +function YakuList({ + result, + preferEnglish, + empty, +}: { + result: Hand13Result + preferEnglish: boolean + empty: string +}) { + const names = preferEnglish ? result.yaku_names_en : result.yaku_names + const shown = names.length ? names : result.yaku_ids.map((id) => `Yaku #${id}`) + + return ( +
+ {shown.length ? shown.join(' / ') : empty} +
+ ) +} + +function scoreLabel(result: Hand13Result, t: (key: string, options?: Record) => string): string { + const dama = result.dama_point > 0 ? fmtScore(Math.round(result.dama_point)) : '—' + const riichi = result.riichi_point > 0 ? fmtScore(Math.round(result.riichi_point)) : '—' + return `${t('tile.expected_draws_dama')}${dama} / ${t('tile.expected_draws_riichi')}${riichi}` +} diff --git a/frontend/src/tiles/defaults.ts b/frontend/src/tiles/defaults.ts index dcf42907..d4abd108 100644 --- a/frontend/src/tiles/defaults.ts +++ b/frontend/src/tiles/defaults.ts @@ -9,6 +9,7 @@ export type TileId = | 'self-hand' | 'board' | 'recommendations' + | 'expected-draws' | 'risk-chart' | 'opponents' | 'events' @@ -33,6 +34,7 @@ export const ALL_TILES: TileId[] = [ 'self-hand', 'board', 'recommendations', + 'expected-draws', 'risk-chart', 'opponents', 'events', @@ -65,6 +67,7 @@ const LG_LAYOUT: LayoutItem[] = [ { i: 'opponents', x: 3, y: 19, w: 3, h: 6, minW: 2, minH: 4 }, { i: 'proxy-control', x: 6, y: 19, w: 6, h: 6, minW: 2, minH: 2, maxH: 6 }, { i: 'board', x: 0, y: 25, w: 6, h: 14, minW: 4, minH: 8 }, + { i: 'expected-draws', x: 6, y: 25, w: 6, h: 8, minW: 4, minH: 5 }, ] const MD_LAYOUT: LayoutItem[] = [ @@ -84,6 +87,7 @@ const MD_LAYOUT: LayoutItem[] = [ { i: 'player-3', x: 8, y: 18, w: 2, h: 8, minW: 2, minH: 6 }, { i: 'proxy-control', x: 0, y: 26, w: 10, h: 4, minW: 2, minH: 2, maxH: 6 }, { i: 'board', x: 0, y: 30, w: 6, h: 14, minW: 4, minH: 8 }, + { i: 'expected-draws', x: 6, y: 30, w: 4, h: 8, minW: 4, minH: 5 }, ] const SM_LAYOUT: LayoutItem[] = [ @@ -103,6 +107,7 @@ const SM_LAYOUT: LayoutItem[] = [ { i: 'bot-action', x: 0, y: 43, w: 6, h: 3, minW: 3, minH: 2 }, { i: 'bot-show', x: 0, y: 46, w: 6, h: 5, minW: 2, minH: 3 }, { i: 'board', x: 0, y: 51, w: 6, h: 16, minW: 4, minH: 8 }, + { i: 'expected-draws', x: 0, y: 67, w: 6, h: 8, minW: 4, minH: 5 }, ] const XS_LAYOUT: LayoutItem[] = ALL_TILES.map((id, i) => ({ @@ -145,6 +150,7 @@ const LG_LAYOUT_3P: LayoutItem[] = [ { i: 'recommendations', x: 0, y: 25, w: 6, h: 8, minW: 2, minH: 4 }, { i: 'risk-chart', x: 6, y: 25, w: 6, h: 8, minW: 2, minH: 4 }, { i: 'board', x: 0, y: 33, w: 6, h: 14, minW: 4, minH: 8 }, + { i: 'expected-draws', x: 6, y: 33, w: 6, h: 8, minW: 4, minH: 5 }, ] const MD_LAYOUT_3P: LayoutItem[] = [ @@ -163,6 +169,7 @@ const MD_LAYOUT_3P: LayoutItem[] = [ { i: 'recommendations', x: 0, y: 25, w: 5, h: 8, minW: 2, minH: 4 }, { i: 'risk-chart', x: 5, y: 25, w: 5, h: 8, minW: 2, minH: 4 }, { i: 'board', x: 0, y: 33, w: 6, h: 14, minW: 4, minH: 8 }, + { i: 'expected-draws', x: 6, y: 33, w: 4, h: 8, minW: 4, minH: 5 }, ] const SM_LAYOUT_3P: LayoutItem[] = [ @@ -181,6 +188,7 @@ const SM_LAYOUT_3P: LayoutItem[] = [ { i: 'bot-show', x: 0, y: 46, w: 6, h: 5, minW: 2, minH: 3 }, //{ i: 'recommendations', x: 0, y: 10, w: 6, h: 8, minW: 2, minH: 4 }, { i: 'board', x: 0, y: 51, w: 6, h: 16, minW: 4, minH: 8 }, + { i: 'expected-draws', x: 0, y: 67, w: 6, h: 8, minW: 4, minH: 5 }, ] const ALL_TILES_3P: TileId[] = ALL_TILES.filter((id) => id !== 'player-3') @@ -214,6 +222,7 @@ export const TILE_TITLES: Record = { 'self-hand': 'Self Hand', 'board': 'Board', 'recommendations': 'Recommendations', + 'expected-draws': 'Expected Draws', 'risk-chart': 'Mixed Risk', 'opponents': 'Opponents', 'events': 'Game Events', diff --git a/frontend/src/tiles/registry.tsx b/frontend/src/tiles/registry.tsx index 6ff30d16..8ae27520 100644 --- a/frontend/src/tiles/registry.tsx +++ b/frontend/src/tiles/registry.tsx @@ -3,6 +3,7 @@ import { PlayerTile } from './PlayerTile' import { SelfHandTile } from './SelfHandTile' import { BoardTile } from './BoardTile' import { RecommendationsTile } from './RecommendationsTile' +import { ExpectedDrawsTile } from './ExpectedDrawsTile' import { RiskChartTile } from './RiskChartTile' import { OpponentsTile } from './OpponentsTile' import { EventsTile } from './EventsTile' @@ -23,6 +24,7 @@ export function renderTile(id: TileId, bp: Breakpoint) { case 'self-hand': return case 'board': return case 'recommendations': return + case 'expected-draws': return case 'risk-chart': return case 'opponents': return case 'events': return diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7996415f..bf1d3c98 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -295,6 +295,8 @@ export type Hand13Result = { riichi_point: number mixed_round_point: number yaku_ids: number[] + yaku_names: string[] + yaku_names_en: string[] } export type DiscardCandidate = { discard: string; result: Hand13Result } diff --git a/src/analysis/improves.rs b/src/analysis/improves.rs index 53154596..1dba5e3e 100644 --- a/src/analysis/improves.rs +++ b/src/analysis/improves.rs @@ -248,6 +248,8 @@ pub fn analyze_13(info: &PlayerInfo34) -> Hand13Result { let mut dama_point = 0.0; let mut riichi_point = 0.0; let mut yaku_ids: Vec = Vec::new(); + let mut yaku_names: Vec = Vec::new(); + let mut yaku_names_en: Vec = Vec::new(); if cur_shanten == 0 && !current_waits.is_empty() { // Compute dora set from indicators. let dora: Vec<_> = info.dora_indicators.iter().map(|d| d.dora_next()).collect(); @@ -259,6 +261,8 @@ pub fn analyze_13(info: &PlayerInfo34) -> Hand13Result { dama_point = est.dama_point; riichi_point = est.riichi_point; yaku_ids = est.yaku_ids; + yaku_names = est.yaku_names; + yaku_names_en = est.yaku_names_en; if !est.has_yaku && is_open { // Open & yakuless: cannot win → zero out point expectation. avg_agari_rate = 0.0; @@ -318,6 +322,8 @@ pub fn analyze_13(info: &PlayerInfo34) -> Hand13Result { riichi_point, mixed_round_point, yaku_ids, + yaku_names, + yaku_names_en, } } diff --git a/src/analysis/result.rs b/src/analysis/result.rs index 02efe8f9..67c9bc5c 100644 --- a/src/analysis/result.rs +++ b/src/analysis/result.rs @@ -102,6 +102,10 @@ pub struct Hand13Result { pub mixed_round_point: f64, /// Yaku ids encountered across waits (riichienv yaku ids). pub yaku_ids: Vec, + /// Japanese yaku names matching `yaku_ids`. + pub yaku_names: Vec, + /// English yaku names matching `yaku_ids`. + pub yaku_names_en: Vec, } /// One discard option from a 14-tile state. diff --git a/src/analysis/score.rs b/src/analysis/score.rs index 377aa888..acfe042f 100644 --- a/src/analysis/score.rs +++ b/src/analysis/score.rs @@ -10,6 +10,7 @@ use riichienv_core::hand_evaluator::HandEvaluator; use riichienv_core::types::{Conditions, Meld as RiMeld, MeldType as RiMeldType, Wind}; +use riichienv_core::yaku; use super::hand::{Meld34, Meld34Kind, PlayerInfo34}; use super::tile::{Tile34, HONOR_BASE}; @@ -24,6 +25,10 @@ pub struct ScoreEstimate { pub riichi_point: f64, /// Yaku ids encountered across all waits (union, deduplicated). pub yaku_ids: Vec, + /// Japanese yaku names matching `yaku_ids`. + pub yaku_names: Vec, + /// English yaku names matching `yaku_ids`. + pub yaku_names_en: Vec, /// Whether at least one wait produces a valid (≥1 han) winning hand. pub has_yaku: bool, } @@ -210,10 +215,30 @@ pub fn expectation(info: &PlayerInfo34, waits: &Waits, allow_riichi: bool) -> Sc return ScoreEstimate::default(); } + let yaku_ids: Vec = yaku_set.into_iter().collect(); + let yaku_names: Vec = yaku_ids + .iter() + .map(|&id| { + yaku::get_yaku_by_id(id) + .map(|y| y.name) + .unwrap_or_else(|| format!("Yaku #{id}")) + }) + .collect(); + let yaku_names_en: Vec = yaku_ids + .iter() + .map(|&id| { + yaku::get_yaku_by_id(id) + .map(|y| y.name_en) + .unwrap_or_else(|| format!("Yaku #{id}")) + }) + .collect(); + ScoreEstimate { dama_point: sum_dama / total_w as f64, riichi_point: sum_riichi / total_w as f64, - yaku_ids: yaku_set.into_iter().collect(), + yaku_ids, + yaku_names, + yaku_names_en, has_yaku, } }