feat(items): 新增掉落模拟器与概率表网页 loot.html (Issue #110)
This commit is contained in:
commit
9103ee3b29
|
|
@ -0,0 +1,599 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>d2web · 掉落模拟器与精确概率表(Issue #110)</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg-main: #0b0906;
|
||||
--bg-panel: rgba(22, 18, 14, 0.94);
|
||||
--border-color: #382d22;
|
||||
--gold-primary: #c8a15a;
|
||||
--gold-light: #e8dcc8;
|
||||
--text-dim: #9c8d78;
|
||||
--btn-bg: #221c15;
|
||||
--btn-hover: #3a2f24;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--bg-main);
|
||||
color: var(--gold-light);
|
||||
font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
header {
|
||||
height: 42px;
|
||||
background: #14100c;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--gold-primary);
|
||||
letter-spacing: 0.06em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
header a {
|
||||
color: var(--gold-primary);
|
||||
text-decoration: none;
|
||||
margin-left: 12px;
|
||||
}
|
||||
header a:hover { text-decoration: underline; }
|
||||
|
||||
/* 双栏主容器 */
|
||||
#app-container {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
height: calc(100vh - 42px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 侧边控制栏 */
|
||||
.sidebar {
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 10px 14px;
|
||||
background: #1a1510;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--gold-primary);
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 表单元素 */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
input, select {
|
||||
background: #18140f;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--gold-light);
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
input[type="number"] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--gold-primary);
|
||||
}
|
||||
|
||||
.badge-section {
|
||||
border-top: 1px dashed var(--border-color);
|
||||
padding-top: 10px;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
color: var(--gold-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 按钮组 */
|
||||
.btn {
|
||||
background: var(--btn-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--gold-light);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--btn-hover);
|
||||
border-color: var(--gold-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #3d2b18;
|
||||
border-color: #8c6a38;
|
||||
color: #ffdfa9;
|
||||
font-weight: bold;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
background: #543b20;
|
||||
border-color: var(--gold-primary);
|
||||
}
|
||||
.btn.danger { color: #ff6b6b; }
|
||||
.btn.danger:hover:not(:disabled) { border-color: #ff6b6b; }
|
||||
|
||||
/* 右侧主视口区 */
|
||||
#main-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Tab 切换导航 */
|
||||
.tabs-nav {
|
||||
display: flex;
|
||||
background: #14100c;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.tab-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border-color);
|
||||
color: var(--text-dim);
|
||||
padding: 10px 18px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
color: var(--gold-light);
|
||||
background: #1c1813;
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: var(--gold-primary);
|
||||
background: #1f1913;
|
||||
border-bottom: 2px solid var(--gold-primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
.tab-pane {
|
||||
display: none;
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tab-pane.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 统计卡片网格 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #14100c;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.stat-title {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: var(--gold-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.progress-container {
|
||||
background: #14100c;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.progress-bar-track {
|
||||
background: #1c1813;
|
||||
border: 1px solid #382d22;
|
||||
border-radius: 3px;
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar-fill {
|
||||
background: linear-gradient(90deg, #8c6a38, #c8a15a);
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
.data-table-container {
|
||||
background: #14100c;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.table-header-bar {
|
||||
padding: 8px 12px;
|
||||
background: #1c1813;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
color: var(--gold-primary);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background: #17130e;
|
||||
color: var(--text-dim);
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #1f1913;
|
||||
}
|
||||
tr:hover td {
|
||||
background: rgba(200, 161, 90, 0.04);
|
||||
}
|
||||
|
||||
/* 品质颜色标识 */
|
||||
.q-normal { color: #c8c8c8; }
|
||||
.q-superior { color: #e8e8e8; }
|
||||
.q-magic { color: #4880ff; }
|
||||
.q-rare { color: #ffff60; }
|
||||
.q-set { color: #00ff00; }
|
||||
.q-unique { color: #c8a15a; }
|
||||
.q-rune { color: #ff8000; }
|
||||
.q-gold { color: #ffd700; }
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>
|
||||
<span>💎</span>
|
||||
<span>暗黑破坏神 2 · 掉落模拟器与精确概率表 (Drop Simulator & Exact Odds)</span>
|
||||
<span style="font-size: 11px; color: var(--text-dim); font-weight: normal;">(Issue #110)</span>
|
||||
</h1>
|
||||
<div>
|
||||
<a href="/acts.html">🎮 战役漫步 (acts.html)</a>
|
||||
<a href="/arena.html">⚔️ 战斗竞技场 (arena.html)</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="app-container">
|
||||
<!-- 左栏:掉落参数与控制 -->
|
||||
<aside class="sidebar" id="control-sidebar">
|
||||
<div class="panel-header">
|
||||
<span>🎮 掉落参数设定 (Drop Config)</span>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="form-group">
|
||||
<label>怪物 / 预设 TC (Monster / TC Preset)</label>
|
||||
<select id="tc-preset">
|
||||
<option value="Act 1 H2H A">Act 1 H2H A (第一幕近战小怪)</option>
|
||||
<option value="Cow (H)">Cow (H) (地狱牛关母牛)</option>
|
||||
<option value="Mephisto (H)">Mephisto (H) (地狱墨菲斯托)</option>
|
||||
<option value="Andariel (H)">Andariel (H) (地狱安达利尔)</option>
|
||||
<option value="Countess (H)">Countess (H) (地狱女伯爵)</option>
|
||||
<option value="Baal (H)">Baal (H) (地狱毁灭之王巴尔)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>自定义 TC 名称 (Custom TC Input)</label>
|
||||
<input type="text" id="tc-input" value="Act 1 H2H A" placeholder="例如 Act 1 H2H A, Cow (H)" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>游戏难度 (Difficulty)</label>
|
||||
<select id="difficulty">
|
||||
<option value="normal">普通 (Normal)</option>
|
||||
<option value="nightmare">噩梦 (Nightmare)</option>
|
||||
<option value="hell" selected>地狱 (Hell)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>寻宝率 MF % (Magic Find)</label>
|
||||
<input type="number" id="magic-find" min="0" max="2000" value="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>游戏总人数 (Game /players)</label>
|
||||
<input type="number" id="game-players" min="1" max="8" value="1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>同队附近人数 (Party Players)</label>
|
||||
<input type="number" id="party-players" min="1" max="8" value="1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="badge-section">
|
||||
<div class="section-title">🎲 蒙特卡洛模拟配置 (Simulation)</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>模拟轮数 (Runs N)</label>
|
||||
<select id="sim-runs">
|
||||
<option value="1000">1,000 轮</option>
|
||||
<option value="10000" selected>10,000 轮</option>
|
||||
<option value="100000">100,000 轮</option>
|
||||
<option value="1000000">1,000,000 轮 (百万)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>随机种子 (Seed, 可选)</label>
|
||||
<input type="number" id="sim-seed" placeholder="随机 (留空)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap;">
|
||||
<button class="btn primary" id="btn-start" style="flex: 1;">▶ 开始模拟</button>
|
||||
<button class="btn" id="btn-pause" disabled>⏸ 暂停</button>
|
||||
<button class="btn danger" id="btn-stop" disabled>⏹ 停止</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 4px;">
|
||||
<button class="btn" id="btn-calc-exact" style="width: 100%;">📐 计算精确概率表</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="badge-section" style="font-size: 11px; color: var(--text-dim); line-height: 1.6;">
|
||||
<div><strong>💡 核心数学机制:</strong></div>
|
||||
<div>• 真实 1.13c <code>rollTreasureClass</code> 递归展开</div>
|
||||
<div>• NoDrop 动态双精度人数缩放</div>
|
||||
<div>• 负数 Picks 确定性枚举,正数 Picks 独立抽取</div>
|
||||
<div>• 多次抽取独立复合概率 <code>1 - (1 - p)^picks</code></div>
|
||||
<div>• 真实 ItemRatio 魔法寻宝递减与品质掷骰</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右栏:双 Tab 主展示区 -->
|
||||
<main id="main-area">
|
||||
<div class="tabs-nav">
|
||||
<button class="tab-btn active" data-tab="tab-sim">🎲 蒙特卡洛掉落模拟器 (Drop Simulator)</button>
|
||||
<button class="tab-btn" data-tab="tab-exact">📐 精确掉落概率表与反查 (Exact Odds & Search)</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- Tab 1: 蒙特卡洛掉落模拟器 -->
|
||||
<div class="tab-pane active" id="tab-sim">
|
||||
<!-- 运行进度条 -->
|
||||
<div class="progress-container">
|
||||
<div class="progress-info">
|
||||
<span id="sim-progress-text">就绪:点击「开始模拟」运行 Monte Carlo 掉落抽样</span>
|
||||
<span>Chunked non-blocking async loop</span>
|
||||
</div>
|
||||
<div class="progress-bar-track">
|
||||
<div class="progress-bar-fill" id="sim-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计指标网格 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-title">总模拟轮数 (Total Runs)</span>
|
||||
<span class="stat-value" id="stat-total-runs">0</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-title">总掉落物品数 (Items Dropped)</span>
|
||||
<span class="stat-value" id="stat-total-drops">0</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-title">平均每轮掉落 (Avg Drops/Run)</span>
|
||||
<span class="stat-value" id="stat-avg-drops">0.00</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-title">空轮 NoDrop 占比 (Empty Runs)</span>
|
||||
<span class="stat-value" id="stat-nodrop-runs">0 (0.00%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 品质分布直方图 -->
|
||||
<div class="data-table-container">
|
||||
<div class="table-header-bar">
|
||||
<span>📊 物品品质分布统计 (Quality Distribution & Histogram)</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 180px;">品质类别 (Quality Tier)</th>
|
||||
<th style="width: 110px;">掉落件数 (Count)</th>
|
||||
<th style="width: 100px;">占比 (Percentage)</th>
|
||||
<th>分布直方图 (Histogram)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="quality-tbody">
|
||||
<tr><td colspan="4" style="text-align: center; color: var(--text-dim);">暂无模拟数据</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 详细掉落物列表 -->
|
||||
<div class="data-table-container">
|
||||
<div class="table-header-bar">
|
||||
<span>📦 模拟掉落物品汇总清单 (Dropped Items Summary)</span>
|
||||
<input type="search" id="items-filter" placeholder="筛选物品代码或名称..." style="padding: 2px 8px; font-size: 11px; width: 180px;" />
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 100px;">物品代码 (Code)</th>
|
||||
<th style="width: 240px;">物品名称 (Name)</th>
|
||||
<th style="width: 110px;">品质 (Quality)</th>
|
||||
<th style="width: 120px;">掉落总数 (Count)</th>
|
||||
<th style="width: 120px;">每轮期望掉落率 (%/Run)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="items-tbody">
|
||||
<tr><td colspan="5" style="text-align: center; color: var(--text-dim);">暂无掉落物记录</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: 精确掉落概率表与反查 -->
|
||||
<div class="tab-pane" id="tab-exact">
|
||||
<!-- 概要指标条 -->
|
||||
<div class="stat-card" style="margin-bottom: 16px;" id="exact-tc-info">
|
||||
正在加载 TreasureClass 树形解析数据...
|
||||
</div>
|
||||
|
||||
<!-- 物品反查搜索栏 -->
|
||||
<div class="data-table-container">
|
||||
<div class="table-header-bar">
|
||||
<span>🔍 物品全局掉落源反查 (Reverse TC Lookup: type Shako, uap, r33, cap...)</span>
|
||||
<span id="reverse-match-count" style="font-size: 11px; color: var(--text-dim); font-weight: normal;"></span>
|
||||
</div>
|
||||
<div style="padding: 10px 14px; background: #16120d; border-bottom: 1px solid var(--border-color);">
|
||||
<input type="search" id="reverse-search-input" placeholder="输入物品代码或名称,例如 Shako, uap, r33, cap..." style="width: 100%; padding: 7px 10px; font-size: 13px;" />
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 220px;">所属掉落表 (Treasure Class)</th>
|
||||
<th style="width: 200px;">匹配物品 (Matched Item)</th>
|
||||
<th style="width: 70px;">挑选次数</th>
|
||||
<th style="width: 110px;">单次概率 (Single)</th>
|
||||
<th style="width: 120px;">综合概率 (Compound)</th>
|
||||
<th style="width: 140px;">掉落几率比 (1 in X Odds)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reverse-tbody">
|
||||
<tr><td colspan="6" style="text-align: center; color: var(--text-dim); padding: 18px;">在上方搜索框输入物品代码或名称(例如 Shako、uap、r33、cap)以反查可掉落的 TC 列表</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 当前 TC 全叶子节点精确概率表 -->
|
||||
<div class="data-table-container">
|
||||
<div class="table-header-bar">
|
||||
<span>📐 当前 TC 叶子物品精确概率表 (Exact Analytic Drop Table)</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50px;">排名</th>
|
||||
<th style="width: 100px;">物品代码</th>
|
||||
<th style="width: 240px;">物品名称</th>
|
||||
<th style="width: 120px;">单次掉落概率</th>
|
||||
<th style="width: 120px;">综合概率 1-(1-p)^picks</th>
|
||||
<th style="width: 120px;">每轮期望掉落数</th>
|
||||
<th style="width: 140px;">几率比 (1 in X Odds)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="exact-tbody">
|
||||
<tr><td colspan="7" style="text-align: center; color: var(--text-dim);">正在计算...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/loot.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,251 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import {
|
||||
calculateDirectProbabilities,
|
||||
calculateCompoundProbability,
|
||||
calculateAnalyticProbabilities,
|
||||
buildReverseLookupIndex,
|
||||
reverseLookup,
|
||||
runMonteCarloSimulation,
|
||||
loadCanonicalTreasureClasses,
|
||||
CANONICAL_ITEM_NAMES,
|
||||
type ReverseLookupEntry,
|
||||
} from '../src/loot.ts'
|
||||
|
||||
describe('Loot Drop Simulator & Exact Probability Table (Issue #110)', () => {
|
||||
const { tcTable, autoTcs } = loadCanonicalTreasureClasses()
|
||||
|
||||
describe('1. Analytic Expansion Canonical Probabilities (Act 1 H2H A)', () => {
|
||||
it('matches canonical probabilities for Act 1 H2H A direct drop rates', () => {
|
||||
// Act 1 H2H A base parameters:
|
||||
// picks: 1, NoDrop: 100, TotalProb: 60, TotalWeight: 160
|
||||
// Expected direct probabilities:
|
||||
// NoDrop: 100 / 160 = 0.625 (62.5%)
|
||||
// gld: 21 / 160 = 0.13125 (13.125%)
|
||||
// Act 1 Equip A: 16 / 160 = 0.10 (10%)
|
||||
// Act 1 Junk: 21 / 160 = 0.13125 (13.125%)
|
||||
// Act 1 Good: 2 / 160 = 0.0125 (1.25%)
|
||||
const direct = calculateDirectProbabilities('Act 1 H2H A', {
|
||||
tcTable,
|
||||
autoTcs,
|
||||
gamePlayers: 1,
|
||||
partyPlayers: 1,
|
||||
})
|
||||
|
||||
expect(direct.picks).toBe(1)
|
||||
expect(direct.noDrop).toBe(100)
|
||||
expect(direct.totalWeight).toBe(160)
|
||||
|
||||
const pNoDrop = direct.probabilities.get('NoDrop')!
|
||||
const pGld = direct.probabilities.get('gld')!
|
||||
const pEquip = direct.probabilities.get('Act 1 Equip A')!
|
||||
const pJunk = direct.probabilities.get('Act 1 Junk')!
|
||||
const pGood = direct.probabilities.get('Act 1 Good')!
|
||||
|
||||
expect(pNoDrop).toBeCloseTo(0.625, 6) // 62.5%
|
||||
expect(pGld).toBeCloseTo(0.13125, 6) // 13.125%
|
||||
expect(pEquip).toBeCloseTo(0.10, 6) // 10.0%
|
||||
expect(pJunk).toBeCloseTo(0.13125, 6) // 13.125%
|
||||
expect(pGood).toBeCloseTo(0.0125, 6) // 1.25%
|
||||
|
||||
// Sum of all direct branch probabilities must equal exactly 1.0 (100%)
|
||||
const sum = pNoDrop + pGld + pEquip + pJunk + pGood
|
||||
expect(sum).toBeCloseTo(1.0, 6)
|
||||
})
|
||||
|
||||
it('propagates direct probabilities to analytic result object', () => {
|
||||
const analytic = calculateAnalyticProbabilities('Act 1 H2H A', {
|
||||
tcTable,
|
||||
autoTcs,
|
||||
gamePlayers: 1,
|
||||
partyPlayers: 1,
|
||||
})
|
||||
|
||||
expect(analytic.direct.get('NoDrop')).toBeCloseTo(0.625, 6)
|
||||
expect(analytic.direct.get('gld')).toBeCloseTo(0.13125, 6)
|
||||
expect(analytic.direct.get('Act 1 Equip A')).toBeCloseTo(0.10, 6)
|
||||
expect(analytic.direct.get('Act 1 Junk')).toBeCloseTo(0.13125, 6)
|
||||
expect(analytic.direct.get('Act 1 Good')).toBeCloseTo(0.0125, 6)
|
||||
|
||||
expect(analytic.noDropSingleProb).toBeCloseTo(0.625, 6)
|
||||
expect(analytic.noDropCompoundProb).toBeCloseTo(0.625, 6)
|
||||
|
||||
// Leaf items include gld with single probability 0.13125
|
||||
const gldLeaf = analytic.leafMap.get('gld')
|
||||
expect(gldLeaf).toBeDefined()
|
||||
expect(gldLeaf?.singleProbability).toBeCloseTo(0.13125, 6)
|
||||
expect(gldLeaf?.compoundProbability).toBeCloseTo(0.13125, 6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Compound Probability Calculation for picks > 1', () => {
|
||||
it('calculates compound probability 1 - (1 - p)^picks accurately', () => {
|
||||
// 7 picks with p = 0.1: 1 - 0.9^7 = 0.5217031
|
||||
expect(calculateCompoundProbability(0.1, 7)).toBeCloseTo(1 - Math.pow(0.9, 7), 8)
|
||||
|
||||
// 7 picks with p = 0.5: 1 - 0.5^7 = 0.9921875
|
||||
expect(calculateCompoundProbability(0.5, 7)).toBeCloseTo(1 - Math.pow(0.5, 7), 8)
|
||||
|
||||
// 1 pick equals single probability
|
||||
expect(calculateCompoundProbability(0.25, 1)).toBe(0.25)
|
||||
|
||||
// Boundary values
|
||||
expect(calculateCompoundProbability(0, 7)).toBe(0)
|
||||
expect(calculateCompoundProbability(1, 7)).toBe(1)
|
||||
expect(calculateCompoundProbability(0.5, -2)).toBe(1) // deterministic picks
|
||||
})
|
||||
|
||||
it('never exceeds 1.0 across extensive parameter sweeps', () => {
|
||||
const pickCounts = [1, 2, 3, 4, 5, 6, 7, 8, 10, 20, 50, 100, 1000]
|
||||
const probabilities = [0.0001, 0.001, 0.01, 0.05, 0.1, 0.2, 0.33, 0.5, 0.75, 0.9, 0.99, 0.9999, 1.0, 1.5]
|
||||
|
||||
for (const picks of pickCounts) {
|
||||
for (const p of probabilities) {
|
||||
const cp = calculateCompoundProbability(p, picks)
|
||||
expect(cp).toBeGreaterThanOrEqual(0)
|
||||
expect(cp).toBeLessThanOrEqual(1.0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('strictly exhibits monotonicity with respect to pick count for p in (0, 1)', () => {
|
||||
const p = 0.15
|
||||
let prev = calculateCompoundProbability(p, 1)
|
||||
for (let picks = 2; picks <= 20; picks++) {
|
||||
const curr = calculateCompoundProbability(p, picks)
|
||||
expect(curr).toBeGreaterThan(prev)
|
||||
expect(curr).toBeLessThanOrEqual(1.0)
|
||||
prev = curr
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Monte Carlo Convergence within 99% Confidence Interval', () => {
|
||||
it('converges to analytic drop probabilities within 99% CI for Act 1 H2H A', async () => {
|
||||
const runs = 30000
|
||||
const seed = 424242
|
||||
|
||||
const result = await runMonteCarloSimulation({
|
||||
tcName: 'Act 1 H2H A',
|
||||
runs,
|
||||
gamePlayers: 1,
|
||||
partyPlayers: 1,
|
||||
seed,
|
||||
tcTable,
|
||||
autoTcs,
|
||||
})
|
||||
|
||||
expect(result.totalRuns).toBe(runs)
|
||||
|
||||
// True analytic probabilities
|
||||
const pExpectedNoDrop = 0.625 // 100 / 160
|
||||
const pExpectedGld = 0.13125 // 21 / 160
|
||||
|
||||
// Observed sample proportions
|
||||
const pObservedNoDrop = result.emptyRuns / runs
|
||||
const gldSummary = result.itemSummaries.find(it => it.code === 'gld')
|
||||
const pObservedGld = (gldSummary?.count ?? 0) / runs
|
||||
|
||||
// 99% Confidence Interval Critical Value z = 2.575829
|
||||
const z99 = 2.575829
|
||||
const seNoDrop = Math.sqrt((pExpectedNoDrop * (1 - pExpectedNoDrop)) / runs)
|
||||
const seGld = Math.sqrt((pExpectedGld * (1 - pExpectedGld)) / runs)
|
||||
|
||||
const marginNoDrop = z99 * seNoDrop
|
||||
const marginGld = z99 * seGld
|
||||
|
||||
const diffNoDrop = Math.abs(pObservedNoDrop - pExpectedNoDrop)
|
||||
const diffGld = Math.abs(pObservedGld - pExpectedGld)
|
||||
|
||||
expect(diffNoDrop).toBeLessThanOrEqual(marginNoDrop)
|
||||
expect(diffGld).toBeLessThanOrEqual(marginGld)
|
||||
})
|
||||
|
||||
it('verifies player scaling reduces empty runs under /players 8', async () => {
|
||||
const runs = 10000
|
||||
const seed = 999111
|
||||
|
||||
const resultSolo8 = await runMonteCarloSimulation({
|
||||
tcName: 'Act 1 H2H A',
|
||||
runs,
|
||||
gamePlayers: 8,
|
||||
partyPlayers: 1,
|
||||
seed,
|
||||
tcTable,
|
||||
autoTcs,
|
||||
})
|
||||
|
||||
// Under gamePlayers = 8, partyPlayers = 1:
|
||||
// effectivePlayers = 1 + trunc(7/2) = 4
|
||||
// Scaled NoDrop = trunc(60 * (100/160)^4 / (1 - (100/160)^4)) = 10
|
||||
// Total = 70. Expected empty rate = 10 / 70 ~ 14.28%
|
||||
const observedRate8 = resultSolo8.emptyRuns / runs
|
||||
const expectedRate8 = 10 / 70
|
||||
const se8 = Math.sqrt((expectedRate8 * (1 - expectedRate8)) / runs)
|
||||
|
||||
expect(Math.abs(observedRate8 - expectedRate8)).toBeLessThanOrEqual(2.576 * se8)
|
||||
expect(observedRate8).toBeLessThan(0.20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. Reverse Lookup Indexing All Eligible Items', () => {
|
||||
let reverseIndex: Map<string, ReverseLookupEntry[]>
|
||||
|
||||
beforeAll(() => {
|
||||
reverseIndex = buildReverseLookupIndex(tcTable, autoTcs)
|
||||
})
|
||||
|
||||
it('indexes all eligible unique leaf items', () => {
|
||||
// 605 distinct item codes (604 items + unified gold)
|
||||
expect(reverseIndex.size).toBe(605)
|
||||
expect(reverseIndex.has('gld')).toBe(true)
|
||||
expect(reverseIndex.has('uap')).toBe(true)
|
||||
expect(reverseIndex.has('r33')).toBe(true)
|
||||
expect(reverseIndex.has('cap')).toBe(true)
|
||||
})
|
||||
|
||||
it('finds dropping TCs when searching for Shako / uap', () => {
|
||||
const byCode = reverseLookup(reverseIndex, 'uap', CANONICAL_ITEM_NAMES)
|
||||
expect(byCode.length).toBeGreaterThan(0)
|
||||
const uapEntry = byCode.find(r => r.matchedCode === 'uap')!
|
||||
expect(uapEntry).toBeDefined()
|
||||
expect(uapEntry.entries.length).toBeGreaterThan(50)
|
||||
|
||||
// Bosses like Mephisto (H) and Baal (H) must be in the drop list for Shako
|
||||
const tcNames = uapEntry.entries.map(e => e.tcName)
|
||||
expect(tcNames).toContain('Mephisto (H)')
|
||||
expect(tcNames).toContain('Baal (H)')
|
||||
|
||||
// Name search for "Shako"
|
||||
const byName = reverseLookup(reverseIndex, 'Shako', CANONICAL_ITEM_NAMES)
|
||||
expect(byName.some(r => r.matchedCode === 'uap')).toBe(true)
|
||||
})
|
||||
|
||||
it('finds dropping TCs when searching for high rune Zod / r33', () => {
|
||||
const byCode = reverseLookup(reverseIndex, 'r33', CANONICAL_ITEM_NAMES)
|
||||
expect(byCode.length).toBeGreaterThan(0)
|
||||
const r33Entry = byCode.find(r => r.matchedCode === 'r33')!
|
||||
expect(r33Entry).toBeDefined()
|
||||
|
||||
const tcNames = r33Entry.entries.map(e => e.tcName)
|
||||
expect(tcNames).toContain('Runes 17')
|
||||
expect(tcNames).toContain('Baal (H)')
|
||||
expect(tcNames).toContain('Cow (H)')
|
||||
|
||||
// Name search for "Zod"
|
||||
const byName = reverseLookup(reverseIndex, 'zod', CANONICAL_ITEM_NAMES)
|
||||
expect(byName.some(r => r.matchedCode === 'r33')).toBe(true)
|
||||
})
|
||||
|
||||
it('finds dropping TCs when searching for cap (armo3)', () => {
|
||||
const byCode = reverseLookup(reverseIndex, 'cap', CANONICAL_ITEM_NAMES)
|
||||
expect(byCode.length).toBeGreaterThan(0)
|
||||
const capEntry = byCode.find(r => r.matchedCode === 'cap')!
|
||||
expect(capEntry).toBeDefined()
|
||||
|
||||
const tcNames = capEntry.entries.map(e => e.tcName)
|
||||
expect(tcNames).toContain('armo3')
|
||||
expect(tcNames).toContain('Act 1 Equip A')
|
||||
expect(tcNames).toContain('Act 1 H2H A')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
/**
|
||||
* The engine is plain TypeScript with no runtime dependencies: Vite only serves
|
||||
* and bundles it. Two entries: the real campaign scene (`acts.html`) and the
|
||||
|
|
@ -43,6 +47,7 @@ export default defineConfig({
|
|||
input: {
|
||||
acts: new URL('./acts.html', import.meta.url).pathname,
|
||||
arena: new URL('./arena.html', import.meta.url).pathname,
|
||||
loot: resolve(__dirname, 'loot.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue