feat(items): 新增掉落模拟器与概率表网页 loot.html (Issue #110)

This commit is contained in:
troytt 2026-09-18 12:20:28 +00:00
parent acd027bd38
commit 6a85c96a5b
4 changed files with 1881 additions and 0 deletions

599
loot.html Normal file
View File

@ -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>

1026
src/loot.ts Normal file

File diff suppressed because one or more lines are too long

251
tests/loot.test.ts Normal file
View File

@ -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')
})
})
})

View File

@ -1,5 +1,9 @@
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
/** /**
* The engine is plain TypeScript with no runtime dependencies: Vite only serves * The engine is plain TypeScript with no runtime dependencies: Vite only serves
* and bundles it. Four entries: the asset inspector (`index.html`), the walkable * and bundles it. Four entries: the asset inspector (`index.html`), the walkable
@ -24,6 +28,7 @@ export default defineConfig({
net: new URL('./net.html', import.meta.url).pathname, net: new URL('./net.html', import.meta.url).pathname,
acts: new URL('./acts.html', import.meta.url).pathname, acts: new URL('./acts.html', import.meta.url).pathname,
arena: new URL('./arena.html', import.meta.url).pathname, arena: new URL('./arena.html', import.meta.url).pathname,
loot: resolve(__dirname, 'loot.html'),
}, },
}, },
}, },