-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsimulate_with_plot.py
More file actions
230 lines (197 loc) · 9.78 KB
/
Copy pathsimulate_with_plot.py
File metadata and controls
230 lines (197 loc) · 9.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
"""
抽卡模拟器 —— 图表输出版
用法: python simulate_with_plot.py [垫抽数] [-n 次数] [--pool 卡池] [--guaranteed] [-o 文件]
"""
import argparse
import platform
import sys
import time
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
from core import run_simulation, POOL_CONFIGS
# ── 中文字体设置 ────────────────────────────────────────────
_OS = platform.system()
if _OS == 'Darwin':
plt.rcParams['font.sans-serif'] = ['PingFang HK', 'PingFang SC', 'Arial Unicode MS', 'Heiti TC']
elif _OS == 'Windows':
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
else:
plt.rcParams['font.sans-serif'] = ['WenQuanYi Micro Hei', 'Droid Sans Fallback', 'Noto Sans CJK SC']
plt.rcParams['axes.unicode_minus'] = False
def plot_results(stats: dict, output_file: str) -> None:
cfg = stats['cfg']
n = stats['num_simulations']
pity = stats['initial_pity']
pulls = stats['pulls']
max_pull = stats['max_pull']
spark = cfg['spark']
counts = np.bincount(pulls, minlength=max_pull + 1)
cum_pct = np.cumsum(counts) / n * 100
x = np.arange(1, max_pull + 1)
guaranteed_note = ' ⚑ 翻转保证已激活' if stats['initial_guaranteed'] else ''
fig, axs = plt.subplots(2, 2, figsize=(14, 11))
fig.suptitle(
f'{cfg["display_name"]} 抽卡模拟结果'
f' (垫抽: {pity} | 次数: {n:,}{guaranteed_note})',
fontsize=13, fontweight='bold', y=0.99,
)
ax1, ax2 = axs[0, 0], axs[0, 1]
ax3, ax4 = axs[1, 0], axs[1, 1]
# ── 图1:频率分布直方图 ───────────────────────────────────
ax1.bar(x, counts[1:], color='steelblue', edgecolor='steelblue', alpha=0.7, width=1.0)
ax1.axvline(stats['mean_pulls'], color='red', linestyle='--', linewidth=1.5,
label=f"均值: {stats['mean_pulls']:.1f}")
ax1.axvline(stats['median_pulls'], color='limegreen', linestyle='--', linewidth=1.5,
label=f"中位数: {stats['median_pulls']:.0f}")
ax1.set_title('抽数频率分布')
ax1.set_xlabel('所需抽数')
ax1.set_ylabel('模拟次数')
ax1.set_xlim(0, max_pull + 2)
ax1.legend(fontsize=9)
ax1.yaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _: f'{int(v):,}'))
# ── 图2:累积概率曲线 (CDF) ───────────────────────────────
ax2.plot(x, cum_pct[1:], color='darkorange', linewidth=2)
ax2.set_title('累积出货概率 (CDF)')
ax2.set_xlabel('所需抽数')
ax2.set_ylabel('累积概率 (%)')
ax2.set_xlim(0, max_pull + 2)
ax2.set_ylim(0, 105)
ax2.grid(True, linestyle='--', alpha=0.5)
for p, v in stats['percentiles'].items():
if p in (25, 50, 75, 90):
ax2.plot(v, p, 'ro', markersize=5, zorder=5)
offset_y = -8 if p > 75 else 5
ax2.annotate(f'{p}%: {v}抽', xy=(v, p),
xytext=(v + 2, p + offset_y), fontsize=8,
arrowprops=dict(arrowstyle='->', color='black', lw=0.8))
if spark > 0 and spark <= len(cum_pct):
spark_pct_val = 100 - cum_pct[spark - 1]
ax2.text(max_pull * 0.65, 8,
f'大保底 ({spark}抽)\n触发率: {spark_pct_val:.2f}%',
fontsize=8, bbox=dict(facecolor='tomato', alpha=0.15, boxstyle='round'))
# ── 图3:各6★出货时机区间图 ──────────────────────────────
timing_stats = stats['timing_stats']
n_positions = len(timing_stats)
bar_colors = ['#4fc3f7', '#81c784', '#ffb74d', '#e57373', '#ce93d8', '#80cbc4', '#ffcc80']
for i, (n_star, ts) in enumerate(timing_stats.items()):
color = bar_colors[i % len(bar_colors)]
y = n_positions - 1 - i
ax3.barh(y, ts['p90'] - ts['p10'], left=ts['p10'],
height=0.35, color=color, alpha=0.3)
ax3.barh(y, ts['p75'] - ts['p25'], left=ts['p25'],
height=0.55, color=color, alpha=0.85)
ax3.plot([ts['mean'], ts['mean']], [y - 0.35, y + 0.35],
color='white', linewidth=2.5, zorder=5)
ax3.plot([ts['mean'], ts['mean']], [y - 0.35, y + 0.35],
color='#c62828', linewidth=1.5, zorder=6)
label_x = min(ts['p90'] + 2, max_pull + 2)
ax3.text(label_x, y,
f" 均值 {ts['mean']:.0f}抽\n 常见 {ts['p25']:.0f}–{ts['p75']:.0f}抽",
va='center', fontsize=8, color='#333333')
ax3.set_yticks(list(range(n_positions - 1, -1, -1)))
ax3.set_yticklabels([f'第{n}个6★' for n in timing_stats.keys()], fontsize=9)
ax3.set_xlabel('当前卡池已抽数')
ax3.set_title('各6★出货时机分布\n(深色=25%-75%常见区间 浅色=10%-90% 竖线=均值)')
ax3.set_xlim(0, max_pull + 30)
ax3.set_ylim(-0.8, n_positions - 0.2)
ax3.grid(True, axis='x', linestyle='--', alpha=0.4)
if spark > 0:
ax3.set_xticks(sorted({0, spark // 4, spark // 2, spark * 3 // 4, spark}))
ax3.axvline(spark, color='tomato', linestyle=':', linewidth=1, alpha=0.6)
else:
step = cfg['hard_pity'] // 2
ax3.set_xticks(list(range(0, max_pull + step, step)))
# ── 图4:数据摘要文本 ─────────────────────────────────────
ax4.axis('off')
spark_line = (f' 大保底触发率 : {stats["spark_rate"]*100:.2f}%\n'
if spark > 0 else '')
spook_line = (f' 平均歪往期限定 : {stats["mean_limited"]:.2f} 次\n'
if cfg['limited_spook_rate'] > 0 else '')
gua_line = (' ⚑ 翻转保证已激活\n'
if stats['initial_guaranteed'] else '')
timing_lines = '\n'.join(
f" 第{pos}个6★ 均值{ts['mean']:.0f}抽 "
f"常见 {ts['p25']:.0f}-{ts['p75']:.0f}抽 "
f"(10-90%: {ts['p10']:.0f}-{ts['p90']:.0f}抽)"
for pos, ts in timing_stats.items()
)
summary = (
f"模拟参数\n"
f" 卡池 : {cfg['display_name']}\n"
f" 模拟次数 : {n:,}\n"
f" 初始垫抽 : {pity}\n"
f"{gua_line}"
f"\n"
f"整体统计\n"
f" 均值抽数 : {stats['mean_pulls']:.2f}\n"
f" 中位数抽数 : {stats['median_pulls']:.0f}\n"
f" 标准差 : {stats['stdev_pulls']:.2f}\n"
f" 平均6星数 : {stats['mean_stars']:.2f}\n"
f"{spook_line}"
f"{spark_line}"
f"\n"
f"各6★出货时机(均值 / 常见区间)\n"
f"{timing_lines}"
)
ax4.text(0.05, 0.95, summary, fontsize=9.5, va='top',
transform=ax4.transAxes,
bbox=dict(facecolor='lightyellow', alpha=0.6, boxstyle='round'))
plt.tight_layout(rect=[0, 0, 1, 0.97])
plt.savefig(output_file, dpi=150, bbox_inches='tight')
print(f"图表已保存: {output_file}")
plt.close(fig)
def main() -> None:
pool_choices = list(POOL_CONFIGS.keys())
parser = argparse.ArgumentParser(
description='游戏抽卡概率模拟器(图表输出)',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
'示例:\n'
' python simulate_with_plot.py 30\n'
' python simulate_with_plot.py 0 -n 200000 --pool arknights_limited\n'
' python simulate_with_plot.py 0 --pool arknights_limited --guaranteed -o result.png\n'
),
)
parser.add_argument(
'pity', nargs='?', type=int, default=None,
help='上一个6星后已垫的抽数(默认交互输入)',
)
parser.add_argument('-n', '--simulations', type=int, default=100_000, metavar='N',
help='蒙特卡洛模拟次数(默认: 100000)')
parser.add_argument('--pool', choices=pool_choices, default='default',
help=f'卡池选择(默认: default)')
parser.add_argument('--guaranteed', action='store_true',
help='翻转保证已激活(上次6星歪了),仅对 arknights_limited 有效')
parser.add_argument('-o', '--output', type=str, default=None, metavar='FILE',
help='输出图片文件名(默认自动生成)')
args = parser.parse_args()
cfg = POOL_CONFIGS[args.pool]
max_pity = cfg['hard_pity'] - 1
pity = args.pity
if pity is None:
try:
raw = input(f"请输入已垫抽数 (0–{max_pity},默认为0): ").strip()
pity = int(raw) if raw else 0
except (ValueError, EOFError):
print("输入无效,使用默认值 0")
pity = 0
if not (0 <= pity <= max_pity):
print(f"错误: 垫抽数应在 0 到 {max_pity}之间,当前输入: {pity}", file=sys.stderr)
sys.exit(1)
gua_tag = '_guaranteed' if args.guaranteed else ''
output_file = args.output or f'result_{args.pool}_pity{pity}{gua_tag}.png'
print(f"卡池: {cfg['display_name']} | 模拟次数: {args.simulations:,}")
t0 = time.perf_counter()
stats = run_simulation(pity, args.simulations, args.pool, args.guaranteed)
elapsed = time.perf_counter() - t0
print(f"模拟完成,耗时 {elapsed:.2f}s")
plot_results(stats, output_file)
print(f"\n====== 核心数据 ======")
print(f"均值: {stats['mean_pulls']:.2f} 中位数: {stats['median_pulls']:.0f} 标准差: {stats['stdev_pulls']:.2f}")
for p, v in stats['percentiles'].items():
print(f" {p:>3}% 的玩家在 {v:>4} 抽内出货")
if cfg['spark'] > 0:
print(f"大保底触发率: {stats['spark_rate']*100:.2f}%")
if __name__ == '__main__':
main()