基于蒙特卡洛模拟的专武阵容胜率模拟

17 小时前136 浏览综合
写在前面:最近趁着活动买了甄姬和哪吒专武,但实在中经常一晚上一上午出不来专武,好几局刷新都不出,于是我红温了,故想要研究一下买了专武是否会使得胜率升高。
首先整理一下程序的规则:
1、考虑阵容里面有5兵种SPEAR(枪)、INF(步)、CAV(骑)、ARCH(弓)、CHAR(车),由于弓骑兵不出现在常规的专武队列里面,所以弓骑兵不考虑在内。用户指定可以上几个兵种,所有兵种加起来不超过5个。
2、上述五个兵种分别有7、9、7、7、6个武器,如果不算专武。
3、用户指定哪个兵种拥有“唯一且不可升级”的专属武器并加入到兵种武器池里面。购入专武后,专武不再出现。
4、初始胜率为50%,如果购买专武,则胜率+25%,如果没有专武,则胜率-25%(比如说第一回合的胜率设定是25%),购买专武外的其它武器,胜率+5%。
5、第二回合开始,开启武器市场,每回合4次刷新机会,每次刷新出现3把不一样的武器,假设每个回合可以购买2次装备。同名的武器不可以装备于两个兵身上,但可以升级,升级2次以后不可继续且武器下架。刷新出同名武器以后优先选择升级,没同名则选择购买新武器。如果出现专武,则放弃目前有的装备,不管是否升级,装备专武。在没有行动可以选择的情况下,跳过。
6、购买某个武器后,下一次这个武器刷新概率会提高。
7、五局三胜,只要对局没有结束,武器商店就可以一直刷新。
那么,我们开始讲解程序,首先设定参数:
**模拟次数**
SIMULATIONS = 10000
**随机种子,输入任意数值代表不改变参数情况下每次重新运行程序结果一致,为None时不一致**
SEED = 7
**设定阵容里面每个兵种的数量,这里以精卫、哪吒、王导、张衡、王朗为例,2步兵2枪1弓**
LINEUP = {"SPEAR": 2, "INF": 2, "CAV": 0, "ARCH": 1, "CHAR": 0}
**超级武器被哪个兵种持有**
SUPER_CLASS = "SPEAR"
**假设每回合购买2次武器,每回合刷新4次,一次刷三个武器**
PURCHASES_PER_ROUND = 2
REFRESHES_PER_ROUND = 4
SHOP_OPTIONS = 3
**设置基础胜率等参数**
BASE_WIN = 0.50
SUPER_BONUS = 0.25
NO_SUPER_PENALTY = 0.25
WEAPON_BONUS = 0.05
UPGRADE_BONUS = 0.05
MAX_UPGRADE = 2
**假设购买某个特定武器后,下次刷新它出现的概率会增加15%**
WEIGHT_BOOST_ADD = 0.15
**定义武器池**
INF_WEAPONS  = [f"INF{i}"  for i in range(1, 10)]
SPEAR_WEAP   = [f"SPR{i}"  for i in range(1, 8)]
ARCH_WEAP    = [f"ARC{i}"  for i in range(1, 8)]
CAV_WEAP     = [f"CAV{i}"  for i in range(1, 8)]
CHAR_WEAP    = [f"CHR{i}"  for i in range(1, 7)]
CLASS_TO_POOL = {"INF": INF_WEAPONS, "SPEAR": SPEAR_WEAP, "ARCH": ARCH_WEAP, "CAV": CAV_WEAP, "CHAR": CHAR_WEAP}
**定义专武属于哪个兵种**
def super_name(cls: str) -> str:
    return f"SUPER_{cls}"
def weapon_class(wid: str) -> str:
    if wid.startswith("SUPER_"):
        return wid.split("_", 1)[1]
    pfx = wid[:3]
    if pfx == "INF": return "INF"
    if pfx == "SPR": return "SPEAR"
    if pfx == "ARC": return "ARCH"
    if pfx == "CAV": return "CAV"
    if pfx == "CHR": return "CHAR"
    raise ValueError(f"Unknown weapon id {wid}")
**储存武器的变量,weapon代表选择了哪个武器,upgrades代表升级了几次**
@dataclass
class Slot:
    weapon: Optional[str] = None
    upgrades: int = 0
**定义商店状态,slots是目前场上的每个兵的武器状态,就是他们装备了什么,升级了几次。has_super定义了专武是否被买走。owned变量记录什么武器已经被买过了。upgrades_by_weapon记录每个武器分别升级了几次,如果升级过两次那就会下架。super_available是指池子中是否还存在专武。banned_weapons用来记录哪些武器已经下架了。**
@dataclass
class TeamState:
    slots: Dict[str, List[Slot]] = field(default_factory=dict)
    has_super: bool = False
    owned: Dict[str, bool] = field(default_factory=dict)
    upgrades_by_weapon: Dict[str, int] = field(default_factory=dict)
    super_available: bool = True
    banned_weapons: Dict[str, bool] = field(default_factory=dict)
**读入数据**
    def __post_init__(self):
        for cls, cnt in LINEUP.items():
            self.slots[cls] = [Slot() for _ in range(cnt)]
        self.super_available = True
**除了专武外,目前队伍里面有多少武器**
    def total_weapon_bonus_count(self) -> int:
        return sum(1 for lst in self.slots.values() for s in lst if s.weapon and not s.weapon.startswith("SUPER_"))
**每个武器升级了多少次了(排除专武)**
    def total_upgrade_levels(self) -> int:
        return sum(s.upgrades for lst in self.slots.values() for s in lst if s.weapon and not s.weapon.startswith("SUPER_"))
**如果已经装备专武,标记专武已经装备,执行和专武装备有关的逻辑,比如说加胜率,并且设置以后不存在超武了**
    def equip_super(self) -> bool:
        cls = SUPER_CLASS
        if len(self.slots.get(cls, [])) == 0:
            return False
        self.slots[cls][0] = Slot(weapon=super_name(cls), upgrades=0)
        self.has_super = True
        self.super_available = False
        self.owned[super_name(cls)] = True
        return True
**判断武器是否还能升级:升级的条件是队伍中要存在这个武器,升级次数小于2,前面已经判断过超武不能升级**
    def can_upgrade(self, wid: str) -> Optional[Tuple[str, int]]:
        cls = weapon_class(wid)
        for i, s in enumerate(self.slots.get(cls, [])):
            if s.weapon == wid and s.upgrades < MAX_UPGRADE:
                return (cls, i)
        return None
**符合条件:没买过特定装备、有空位时,购买装备**
    def can_equip_new(self, wid: str) -> Optional[Tuple[str, int]]:
        if self.owned.get(wid, False):
            return None
        cls = weapon_class(wid)
        for i, s in enumerate(self.slots.get(cls, [])):
            if s.weapon is None:
                return (cls, i)
        return None
**链接上面模块,综合武器购买的核心逻辑**
**队伍里若没专武,遇到专武就装备**
**队伍里若有装备能升级,优先升级**
**如果有新装备,就装备**
    def apply_purchase(self, wid: str) -> bool:
        if wid.startswith("SUPER_"):
            if not self.has_super and wid == super_name(SUPER_CLASS):
                return self.equip_super()
            return False
        up_pos = self.can_upgrade(wid)
        if up_pos is not None:
            cls, idx = up_pos
            self.slots[cls][idx].upgrades += 1
            self.owned[wid] = True
            self.upgrades_by_weapon[wid] = self.slots[cls][idx].upgrades
            if self.upgrades_by_weapon[wid] >= MAX_UPGRADE:
                self.banned_weapons[wid] = True
            return True
        new_pos = self.can_equip_new(wid)
        if new_pos is not None:
            cls, idx = new_pos
            self.slots[cls][idx] = Slot(weapon=wid, upgrades=0)
            self.owned[wid] = True
            self.upgrades_by_weapon[wid] = 0
            return True
        return False
**判断这一回合中所有可能会刷出来的武器,构建武器池**
**没有购买专武时,专武才会出现在池子里面;对于其它武器来说,没升级到2,就会出现在里面**
def build_available_pool(state: TeamState) -> List[str]:
    pool: List[str] = []
    if state.super_available:
        pool.append(super_name(SUPER_CLASS))
    for cls, names in CLASS_TO_POOL.items():
        for wid in names:
            if state.banned_weapons.get(wid, False):
                continue
            pool.append(wid)
    return pool
**模拟武器购买后下次出现概率增加的情况**
def weight_of(wid: str, state: TeamState) -> float:
    if wid.startswith("SUPER_"):
        return 1.0
    return 1.0 + (WEIGHT_BOOST_ADD if state.owned.get(wid, False) else 0.0)
**模拟抽卡和刷新武器**
def sample_shop_options(state: TeamState, k: int) -> List[str]:
    pool = build_available_pool(state)
    if not pool:
        return []
**k是池子大小**
    k = min(k, len(pool))
**item里面表示每个武器,以及他们目前被抽中概率的权重**
    items = [(wid, weight_of(wid, state)) for wid in pool]
    chosen: List[str] = []
    local = items[:]
    for _ in range(k):
        total_w = sum(w for _, w in local)
        r = random.random() * total_w
        acc = 0.0
        pick_idx = 0
        for i, (wid, w) in enumerate(local):
            acc += w
            if r <= acc:
                pick_idx = i
                break
        wid = local[pick_idx][0]
        chosen.append(wid)
        del local[pick_idx]
    return chosen
**模拟商店刷新和购买**
def shop_phase(state: TeamState):
    buys_left = PURCHASES_PER_ROUND
    for _ in range(REFRESHES_PER_ROUND):
        if buys_left <= 0:
            break
        options = sample_shop_options(state, SHOP_OPTIONS)
        if not options:
            continue
        bought = False
        sname = super_name(SUPER_CLASS)
        if sname in options and not state.has_super:
            bought = state.apply_purchase(sname)
        if not bought:
            for wid in options:
                if wid.startswith("SUPER_"):
                    continue
                if state.can_upgrade(wid) is not None:
                    bought = state.apply_purchase(wid)
                    if bought: break
        if not bought:
            for wid in options:
                if wid.startswith("SUPER_"):
                    continue
                if state.can_equip_new(wid) is not None:
                    bought = state.apply_purchase(wid)
                    if bought: break
        if bought:
            buys_left -= 1
**执行单个回合的胜率计算**
def round_win_prob(state: TeamState) -> float:
    p = BASE_WIN
    if state.has_super:
        p += SUPER_BONUS
    else:
        p -= NO_SUPER_PENALTY
    p += WEAPON_BONUS  * state.total_weapon_bonus_count()
    p += UPGRADE_BONUS * state.total_upgrade_levels()
    return max(0.0, min(1.0, p))
**执行单局的胜率计算**
def simulate_one_match() -> Tuple[bool, int]:
    state = TeamState()
    wins = losses = rounds = 0
    while wins < 3 and losses < 3 and rounds < 5:
        rounds += 1
        if random.random() < round_win_prob(state):
            wins += 1
        else:
            losses += 1
        if wins < 3 and losses < 3 and rounds < 5:
            shop_phase(state)
    return (wins >= 3, rounds)
**执行多局的胜率计算**
def simulate_many(n: int, seed: Optional[int] = None) -> Dict[str, float]:
    if seed is not None:
        random.seed(seed)
    wins = 0
    lens = []
    for _ in range(n):
        w, r = simulate_one_match()
        wins += 1 if w else 0
        lens.append(r)
    return {
        "match_win_rate": wins / n,
        "avg_rounds": sum(lens)/n,
        "median_rounds": stats.median(lens),
        "p3_rounds": sum(1 for r in lens if r == 3) / n,
        "p4_rounds": sum(1 for r in lens if r == 4) / n,
        "p5_rounds": sum(1 for r in lens if r == 5) / n,
    }
到这里程序结束了,让我们来运行一下吧:
if __name__ == "__main__":
    res = simulate_many(SIMULATIONS, SEED)
    for k, v in res.items():
        print(f"{k}: {v}")
运行以后,模拟了1000次的结果表明,总胜率是0.654。
我再试试看换个援军,假设阵容是甄姬、陆压、黄月英、诸葛亮、桓玄,那就是3步2枪,胜率理论上是0.63。
其实我们还不知道那个WEIGHT_BOOST_ADD参数具体是多少,我试着调成30%以后,发现援军队胜率变成0.64了,而哪吒队会变成0.649。
可能有人会说那买了装备会导致特定装备概率上升,导致专武更出不来了。为了模拟不出专就什么都不买的情况,只需要对shop_phase做以下修改:
def shop_phase(state: TeamState):
    buys_left = PURCHASES_PER_ROUND
    for _ in range(REFRESHES_PER_ROUND):
        if buys_left <= 0:
            break
        options = sample_shop_options(state, SHOP_OPTIONS)
        if not options:
            continue
        bought = False
        sname = super_name(SUPER_CLASS)
       
        if sname in options and not state.has_super:
            bought = state.apply_purchase(sname)
        if not bought:
            continue  # Skip upgrade and equip steps if no super weapon was found
        if bought:
            buys_left -= 1
运行以后发现哪吒队和援军队的胜率都只有0.477附近了,明显低于之前的规则。
以上纯属娱乐,这是我没有考虑很周到的结果,事实情况肯定和这个不一样。但从这个模拟中可以发现的有:
1、没专武不容易赢的阵容不要不出专武就投,这是不理性的做法,也不要不出专就不买别的东西。
2、应该先积极把一个武器升级满,让他退出武器池,对于提高全局胜率是有利的、
3、这个程序是假设不存在系统制裁你故意不给出专武的,如果存在这种问题那就不好说了。还有一种可能是所有概率都是在无限重复次的独立实验中才会成立的,这也是为什么理论上50%概率发生的事情,实际上你执行10次只出现一次的原因。
3
2