ProbabilityPewter 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ProbabilityPewter/__init__.py +30 -0
- ProbabilityPewter/calculator/__init__.py +5 -0
- ProbabilityPewter/calculator/bayes.py +46 -0
- ProbabilityPewter/calculator/combiner.py +143 -0
- ProbabilityPewter/calculator/series.py +63 -0
- ProbabilityPewter/roll_stats/__init__.py +7 -0
- ProbabilityPewter/roll_stats/combat.py +715 -0
- ProbabilityPewter/roll_stats/compare.py +135 -0
- ProbabilityPewter/roll_stats/dice.py +116 -0
- ProbabilityPewter/visualiser/__init__.py +6 -0
- ProbabilityPewter/visualiser/distributions.py +721 -0
- probabilitypewter-0.5.0.dist-info/METADATA +253 -0
- probabilitypewter-0.5.0.dist-info/RECORD +16 -0
- probabilitypewter-0.5.0.dist-info/WHEEL +5 -0
- probabilitypewter-0.5.0.dist-info/licenses/LICENSE +21 -0
- probabilitypewter-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
"""Combat-odds helpers for tabletop war games.
|
|
2
|
+
|
|
3
|
+
This module provides lightweight combat win-probability tools for three popular
|
|
4
|
+
strategy games. The focus is quick, practical estimation — not a full rules
|
|
5
|
+
emulation.
|
|
6
|
+
|
|
7
|
+
Supported games
|
|
8
|
+
---------------
|
|
9
|
+
Risk
|
|
10
|
+
Uses **exact** probability calculation: all possible dice outcomes are
|
|
11
|
+
enumerated once and cached, so results are instant and perfectly accurate.
|
|
12
|
+
The attacker keeps 1 army in reserve, so a territory with N armies rolls
|
|
13
|
+
min(3, N-1) dice. Ties go to the defender. Pass the **total army count
|
|
14
|
+
of each territory**, e.g. ``risk_odds(10, 8)`` means 10 vs 8 armies —
|
|
15
|
+
identical to what you'd type into riskodds.com.
|
|
16
|
+
|
|
17
|
+
Twilight Imperium 4
|
|
18
|
+
Uses **Monte Carlo simulation** (repeated random battles). Each unit type
|
|
19
|
+
rolls a number of 10-sided dice and hits on a roll >= its combat value.
|
|
20
|
+
Some units (Dreadnought, War Sun) have *Sustain Damage* — they can absorb
|
|
21
|
+
one hit before being destroyed; ``ti_odds({'Dreadnought': 1, 'Cruiser': 2}, {'Fighter': 5})``.
|
|
22
|
+
|
|
23
|
+
Casualty allocation assumes optimal defender strategy (spreading damage
|
|
24
|
+
across fresh Sustain units rather than destroying already-damaged units).
|
|
25
|
+
|
|
26
|
+
Axis & Allies
|
|
27
|
+
Uses **Monte Carlo simulation**. Units roll D6 and hit on a roll <= their
|
|
28
|
+
attack (or defense) value. Attack and defense values differ per unit;
|
|
29
|
+
``aa_odds({'Tank': 3, 'Infantry': 2}, {'Infantry': 4})``.
|
|
30
|
+
|
|
31
|
+
Return format — the ``output`` parameter
|
|
32
|
+
----------------------------------------
|
|
33
|
+
Every public function accepts an ``output`` keyword that controls what is
|
|
34
|
+
returned:
|
|
35
|
+
|
|
36
|
+
* ``'summary'`` (default) — a plain-text string with the headline numbers.
|
|
37
|
+
* ``'result'`` — a :class:`CombatResult` object with all fields accessible.
|
|
38
|
+
* ``'dict'`` — a plain Python dictionary with the same fields.
|
|
39
|
+
* ``'dataframe'`` — a one-row ``pandas.DataFrame`` (pandas must be installed).
|
|
40
|
+
|
|
41
|
+
Use ``output='result'`` when you want to pass the result to :func:`plot_combat`.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from functools import lru_cache
|
|
48
|
+
import itertools
|
|
49
|
+
import random
|
|
50
|
+
from typing import Dict, Iterable, List, Tuple, Union
|
|
51
|
+
|
|
52
|
+
import matplotlib.pyplot as plt
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class UnitProfile:
|
|
57
|
+
"""Internal combat profile for one unit type.
|
|
58
|
+
|
|
59
|
+
This is a *dataclass* — a plain Python class that mainly holds data.
|
|
60
|
+
``@dataclass`` is a decorator that auto-generates ``__init__``, ``__repr__``
|
|
61
|
+
and other boilerplate so you can write ``UnitProfile(name='Cruiser', ...)``
|
|
62
|
+
without typing all that yourself. The ``frozen=True`` flag makes instances
|
|
63
|
+
immutable (like a named tuple) so they can be safely cached.
|
|
64
|
+
|
|
65
|
+
Attributes
|
|
66
|
+
----------
|
|
67
|
+
name : str
|
|
68
|
+
Display name of the unit.
|
|
69
|
+
dice_sides : int
|
|
70
|
+
Number of sides on the die rolled (10 for TI, 6 for A&A).
|
|
71
|
+
attack_target : int
|
|
72
|
+
The number the die roll must reach to score a hit while attacking.
|
|
73
|
+
defense_target : int
|
|
74
|
+
The number the die roll must reach to score a hit while defending.
|
|
75
|
+
compare : str
|
|
76
|
+
``'ge'`` — hit if roll >= target (TI: roll high to hit).
|
|
77
|
+
``'le'`` — hit if roll <= target (A&A: roll low to hit).
|
|
78
|
+
dice_count : int
|
|
79
|
+
How many dice this unit rolls per combat round (default 1; War Sun rolls 3).
|
|
80
|
+
sustain : bool
|
|
81
|
+
If ``True`` the unit can absorb one hit before being destroyed
|
|
82
|
+
(TI Dreadnought / War Sun).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
dice_sides: int
|
|
87
|
+
attack_target: int
|
|
88
|
+
defense_target: int
|
|
89
|
+
compare: str = "le"
|
|
90
|
+
dice_count: int = 1
|
|
91
|
+
sustain: bool = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class CombatResult:
|
|
96
|
+
"""Container returned by every ``*_odds`` function when ``output='result'``.
|
|
97
|
+
|
|
98
|
+
A *dataclass* is a normal Python class that mainly stores data. The
|
|
99
|
+
``@dataclass`` decorator writes the ``__init__`` method for you, so each
|
|
100
|
+
attribute listed below becomes a constructor argument and a readable
|
|
101
|
+
attribute on the object (e.g. ``result.attacker_wins``).
|
|
102
|
+
|
|
103
|
+
Attributes
|
|
104
|
+
----------
|
|
105
|
+
attacker_wins : float
|
|
106
|
+
Probability [0, 1] that the attacker wins the battle.
|
|
107
|
+
defender_wins : float
|
|
108
|
+
Probability [0, 1] that the defender wins the battle.
|
|
109
|
+
draw : float
|
|
110
|
+
Probability [0, 1] that both sides are wiped out simultaneously.
|
|
111
|
+
This is essentially zero for Risk but can occur in simulations.
|
|
112
|
+
avg_att_remaining : float
|
|
113
|
+
Average number of attacker units / armies still alive at the end.
|
|
114
|
+
avg_def_remaining : float
|
|
115
|
+
Average number of defender units / armies still alive at the end.
|
|
116
|
+
summary : str
|
|
117
|
+
Human-readable one-liner with the headline numbers.
|
|
118
|
+
detail : dict
|
|
119
|
+
Extra data depending on method — ``'method'`` key is either
|
|
120
|
+
``'exact'`` (Risk) or ``'simulation'`` (TI / A&A). For exact Risk
|
|
121
|
+
results this also contains the full terminal-state distribution.
|
|
122
|
+
|
|
123
|
+
Example
|
|
124
|
+
-------
|
|
125
|
+
>>> r = risk_odds(8, 5, output='result')
|
|
126
|
+
>>> r.attacker_wins
|
|
127
|
+
0.736396
|
|
128
|
+
>>> plot_combat(r) # pass the result object directly to the plot function
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
attacker_wins: float
|
|
132
|
+
defender_wins: float
|
|
133
|
+
draw: float
|
|
134
|
+
avg_att_remaining: float
|
|
135
|
+
avg_def_remaining: float
|
|
136
|
+
summary: str
|
|
137
|
+
detail: Dict
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
TI4_UNITS: Dict[str, UnitProfile] = {
|
|
141
|
+
"Infantry": UnitProfile("Infantry", dice_sides=10, attack_target=8, defense_target=8, compare="ge"),
|
|
142
|
+
"Infantry+": UnitProfile("Infantry+", dice_sides=10, attack_target=7, defense_target=7, compare="ge"),
|
|
143
|
+
"Fighter": UnitProfile("Fighter", dice_sides=10, attack_target=9, defense_target=9, compare="ge"),
|
|
144
|
+
"Fighter+": UnitProfile("Fighter+", dice_sides=10, attack_target=8, defense_target=8, compare="ge"), #the upgraded versions of the units
|
|
145
|
+
"Destroyer": UnitProfile("Destroyer", dice_sides=10, attack_target=9, defense_target=9, compare="ge"),
|
|
146
|
+
"Destroyer+": UnitProfile("Destroyer+", dice_sides=10, attack_target=8, defense_target=8, compare="ge"),
|
|
147
|
+
"Carrier": UnitProfile("Carrier", dice_sides=10, attack_target=9, defense_target=9, compare="ge"),
|
|
148
|
+
"Cruiser": UnitProfile("Cruiser", dice_sides=10, attack_target=7, defense_target=7, compare="ge"),
|
|
149
|
+
"Cruiser+": UnitProfile("Cruiser+", dice_sides=10, attack_target=6, defense_target=6, compare="ge"),
|
|
150
|
+
"Dreadnought": UnitProfile(
|
|
151
|
+
"Dreadnought", dice_sides=10, attack_target=5, defense_target=5, compare="ge", sustain=True
|
|
152
|
+
),
|
|
153
|
+
"War Sun": UnitProfile(
|
|
154
|
+
"War Sun", dice_sides=10, attack_target=3, defense_target=3, compare="ge", dice_count=3, sustain=True
|
|
155
|
+
),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
AA_UNITS: Dict[str, UnitProfile] = {
|
|
159
|
+
#Stats based on the Axis & Allies: 50th Anniversary Edition of 2008
|
|
160
|
+
"Infantry": UnitProfile("Infantry", dice_sides=6, attack_target=1, defense_target=2, compare="le"),
|
|
161
|
+
"Infantry+": UnitProfile("Infantry+", dice_sides=6, attack_target=2, defense_target=2, compare="le"), #paired with Artillery
|
|
162
|
+
"Artillery": UnitProfile("Artillery", dice_sides=6, attack_target=2, defense_target=2, compare="le"),
|
|
163
|
+
"Tank": UnitProfile("Tank", dice_sides=6, attack_target=3, defense_target=3, compare="le"),
|
|
164
|
+
"Fighter": UnitProfile("Fighter", dice_sides=6, attack_target=3, defense_target=4, compare="le"),
|
|
165
|
+
"Bomber": UnitProfile("Bomber", dice_sides=6, attack_target=4, defense_target=1, compare="le"),
|
|
166
|
+
"Cruiser": UnitProfile("Cruiser", dice_sides=6, attack_target=3, defense_target=3, compare="le"),
|
|
167
|
+
"Carrier": UnitProfile("Carrier", dice_sides=6, attack_target=1, defense_target=3, compare="le"),
|
|
168
|
+
"Destroyer": UnitProfile("Destroyer", dice_sides=6, attack_target=2, defense_target=2, compare="le"),
|
|
169
|
+
"Submarine": UnitProfile("Submarine", dice_sides=6, attack_target=2, defense_target=1, compare="le"),
|
|
170
|
+
"Battleship": UnitProfile(
|
|
171
|
+
"Battleship", dice_sides=6, attack_target=4, defense_target=4, compare="le", sustain=True
|
|
172
|
+
),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _format_result(result: CombatResult, output: str):
|
|
177
|
+
"""Format a CombatResult according to output mode."""
|
|
178
|
+
if output == "summary":
|
|
179
|
+
return result.summary
|
|
180
|
+
if output == "result":
|
|
181
|
+
return result
|
|
182
|
+
if output == "dict":
|
|
183
|
+
return {
|
|
184
|
+
"attacker_wins": result.attacker_wins,
|
|
185
|
+
"defender_wins": result.defender_wins,
|
|
186
|
+
"draw": result.draw,
|
|
187
|
+
"avg_att_remaining": result.avg_att_remaining,
|
|
188
|
+
"avg_def_remaining": result.avg_def_remaining,
|
|
189
|
+
"summary": result.summary,
|
|
190
|
+
"detail": result.detail,
|
|
191
|
+
}
|
|
192
|
+
if output == "dataframe":
|
|
193
|
+
import pandas as pd
|
|
194
|
+
|
|
195
|
+
return pd.DataFrame(
|
|
196
|
+
[
|
|
197
|
+
{
|
|
198
|
+
"attacker_wins": result.attacker_wins,
|
|
199
|
+
"defender_wins": result.defender_wins,
|
|
200
|
+
"draw": result.draw,
|
|
201
|
+
"avg_att_remaining": result.avg_att_remaining,
|
|
202
|
+
"avg_def_remaining": result.avg_def_remaining,
|
|
203
|
+
}
|
|
204
|
+
]
|
|
205
|
+
)
|
|
206
|
+
raise ValueError("output must be one of: 'summary', 'result', 'dict', 'dataframe'")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _risk_round_probs(att_dice: int, def_dice: int) -> Dict[Tuple[int, int], float]:
|
|
210
|
+
"""Per-round casualty probabilities for Risk-style dice comparisons.
|
|
211
|
+
|
|
212
|
+
Returns probabilities keyed by (attacker_losses, defender_losses).
|
|
213
|
+
"""
|
|
214
|
+
outcomes: Dict[Tuple[int, int], float] = {}
|
|
215
|
+
total = 6 ** (att_dice + def_dice)
|
|
216
|
+
|
|
217
|
+
for att_rolls in itertools.product(range(1, 7), repeat=att_dice):
|
|
218
|
+
for def_rolls in itertools.product(range(1, 7), repeat=def_dice):
|
|
219
|
+
a_sorted = sorted(att_rolls, reverse=True)
|
|
220
|
+
d_sorted = sorted(def_rolls, reverse=True)
|
|
221
|
+
pairs = min(att_dice, def_dice)
|
|
222
|
+
a_loss = 0
|
|
223
|
+
d_loss = 0
|
|
224
|
+
for i in range(pairs):
|
|
225
|
+
if a_sorted[i] > d_sorted[i]:
|
|
226
|
+
d_loss += 1
|
|
227
|
+
else:
|
|
228
|
+
a_loss += 1
|
|
229
|
+
key = (a_loss, d_loss)
|
|
230
|
+
outcomes[key] = outcomes.get(key, 0.0) + 1.0 / total
|
|
231
|
+
|
|
232
|
+
return outcomes
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@lru_cache(maxsize=None)
|
|
236
|
+
def _risk_transitions(att_dice: int, def_dice: int) -> Tuple[Tuple[int, int, float], ...]:
|
|
237
|
+
probs = _risk_round_probs(att_dice, def_dice)
|
|
238
|
+
return tuple((a_loss, d_loss, p) for (a_loss, d_loss), p in probs.items())
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _risk_terminal_distribution(attackers: int, defenders: int) -> Dict[Tuple[int, int], float]:
|
|
242
|
+
"""Exact terminal-state distribution for a Risk battle."""
|
|
243
|
+
|
|
244
|
+
@lru_cache(maxsize=None)
|
|
245
|
+
def _solve(a: int, d: int) -> Dict[Tuple[int, int], float]:
|
|
246
|
+
# Attacker stops at 1 army (must keep 1 in the source territory).
|
|
247
|
+
# Defender fights until eliminated (no reserve rule for defenders).
|
|
248
|
+
if a <= 1 or d <= 0:
|
|
249
|
+
return {(a, max(d, 0)): 1.0}
|
|
250
|
+
|
|
251
|
+
adice = min(3, a - 1) # at most 3 dice; reserve army never fights
|
|
252
|
+
ddice = min(2, d)
|
|
253
|
+
combined: Dict[Tuple[int, int], float] = {}
|
|
254
|
+
for a_loss, d_loss, p in _risk_transitions(adice, ddice):
|
|
255
|
+
sub = _solve(a - a_loss, d - d_loss)
|
|
256
|
+
for state, sub_p in sub.items():
|
|
257
|
+
combined[state] = combined.get(state, 0.0) + p * sub_p
|
|
258
|
+
return combined
|
|
259
|
+
|
|
260
|
+
return _solve(int(attackers), int(defenders))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def risk_odds(
|
|
264
|
+
#validated using https://riskodds.com/
|
|
265
|
+
attackers: int,
|
|
266
|
+
defenders: int,
|
|
267
|
+
output: str = "summary"
|
|
268
|
+
):
|
|
269
|
+
"""Exact win-probability for a Risk battle fought to completion.
|
|
270
|
+
|
|
271
|
+
Pass the **total number of armies in each territory**, exactly as you
|
|
272
|
+
would enter them on riskodds.com.
|
|
273
|
+
|
|
274
|
+
Risk dice rules modelled here
|
|
275
|
+
------------------------------
|
|
276
|
+
* The attacker's source territory must always keep **at least 1 army**
|
|
277
|
+
behind, so the attacker rolls ``min(3, attackers - 1)`` dice per round.
|
|
278
|
+
A territory with 2 armies rolls 1 die; with 3 armies, 2 dice; with 4+
|
|
279
|
+
armies, 3 dice.
|
|
280
|
+
* The **defender** rolls up to **2 dice** per round (one per defending
|
|
281
|
+
army, maximum 2); defenders have no reserve rule.
|
|
282
|
+
* Each pair is resolved highest-vs-highest; **ties always go to the
|
|
283
|
+
defender** (attacker must roll strictly higher to win a pair).
|
|
284
|
+
* The battle continues automatically until the attacker is reduced to
|
|
285
|
+
1 army (can no longer attack) or the defender is eliminated.
|
|
286
|
+
* **Draws are impossible in Risk.** The attacker always ends with at
|
|
287
|
+
least 1 army, so both sides can never reach 0 simultaneously. The
|
|
288
|
+
``draw`` field of the returned :class:`CombatResult` is always 0.
|
|
289
|
+
|
|
290
|
+
Results are **exact** — every possible sequence of dice rolls is computed
|
|
291
|
+
analytically using dynamic programming, not simulation.
|
|
292
|
+
|
|
293
|
+
Parameters
|
|
294
|
+
----------
|
|
295
|
+
attackers : int
|
|
296
|
+
Total armies in the **attacking territory** (including the 1 kept in
|
|
297
|
+
reserve). Must be >= 2 to be able to attack; passing 1 returns
|
|
298
|
+
defender wins immediately.
|
|
299
|
+
defenders : int
|
|
300
|
+
Total armies in the **defending territory**.
|
|
301
|
+
output : str
|
|
302
|
+
``'summary'`` (default), ``'result'``, ``'dict'``, or ``'dataframe'``.
|
|
303
|
+
|
|
304
|
+
Returns
|
|
305
|
+
-------
|
|
306
|
+
str, CombatResult, dict, or pandas.DataFrame
|
|
307
|
+
Depending on *output*.
|
|
308
|
+
|
|
309
|
+
Examples
|
|
310
|
+
--------
|
|
311
|
+
>>> risk_odds(10, 8) # territory with 10 armies attacks territory with 8
|
|
312
|
+
'Risk odds - attacker win: ..., avg survivors (A/D): ...'
|
|
313
|
+
|
|
314
|
+
>>> r = risk_odds(10, 8, output='result')
|
|
315
|
+
>>> r.attacker_wins
|
|
316
|
+
0.745...
|
|
317
|
+
>>> plot_combat(r)
|
|
318
|
+
"""
|
|
319
|
+
if attackers < 0 or defenders < 0:
|
|
320
|
+
raise ValueError("attackers and defenders must be >= 0")
|
|
321
|
+
|
|
322
|
+
dist = _risk_terminal_distribution(int(attackers), int(defenders))
|
|
323
|
+
att_win = sum(p for (a, d), p in dist.items() if d == 0)
|
|
324
|
+
def_win = sum(p for (a, d), p in dist.items() if d > 0)
|
|
325
|
+
# Draws are structurally impossible in Risk: the attacker always keeps
|
|
326
|
+
# 1 army in reserve, so both sides can never reach 0 at the same time.
|
|
327
|
+
draw = 0.0
|
|
328
|
+
|
|
329
|
+
avg_att = sum(a * p for (a, _), p in dist.items())
|
|
330
|
+
avg_def = sum(d * p for (_, d), p in dist.items())
|
|
331
|
+
|
|
332
|
+
summary = (
|
|
333
|
+
f"Risk odds - attacker win: {att_win:.2%}, defender win: {def_win:.2%}, "
|
|
334
|
+
f"avg survivors (A/D): {avg_att:.2f}/{avg_def:.2f}"
|
|
335
|
+
)
|
|
336
|
+
result = CombatResult(
|
|
337
|
+
attacker_wins=round(att_win, 6),
|
|
338
|
+
defender_wins=round(def_win, 6),
|
|
339
|
+
draw=round(draw, 6),
|
|
340
|
+
avg_att_remaining=round(avg_att, 3),
|
|
341
|
+
avg_def_remaining=round(avg_def, 3),
|
|
342
|
+
summary=summary,
|
|
343
|
+
detail={"terminal_distribution": dist, "method": "exact"},
|
|
344
|
+
)
|
|
345
|
+
return _format_result(result, output)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _build_force(units: Dict[str, int], catalog: Dict[str, UnitProfile]) -> List[Dict[str, Union[str, int]]]:
|
|
349
|
+
"""Expand {'Unit': n} into a list of unit instances with hit points."""
|
|
350
|
+
if not isinstance(units, dict) or not units:
|
|
351
|
+
raise ValueError("units must be a non-empty dict like {'Cruiser': 2, 'Fighter': 3}")
|
|
352
|
+
|
|
353
|
+
force: List[Dict[str, Union[str, int]]] = []
|
|
354
|
+
for unit_name, count in units.items():
|
|
355
|
+
if unit_name not in catalog:
|
|
356
|
+
supported = ", ".join(sorted(catalog))
|
|
357
|
+
raise ValueError(f"Unknown unit: '{unit_name}'. Supported units: {supported}")
|
|
358
|
+
if not isinstance(count, int) or count < 0:
|
|
359
|
+
raise ValueError("unit counts must be integers >= 0")
|
|
360
|
+
|
|
361
|
+
profile = catalog[unit_name]
|
|
362
|
+
for _ in range(count):
|
|
363
|
+
force.append({
|
|
364
|
+
"name": profile.name,
|
|
365
|
+
"hp": 2 if profile.sustain else 1,
|
|
366
|
+
"max_hp": 2 if profile.sustain else 1,
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
if not force:
|
|
370
|
+
raise ValueError("force cannot be empty")
|
|
371
|
+
return force
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _hit_success(roll: int, target: int, compare: str) -> bool:
|
|
375
|
+
if compare == "ge":
|
|
376
|
+
return roll >= target
|
|
377
|
+
if compare == "le":
|
|
378
|
+
return roll <= target
|
|
379
|
+
raise ValueError("compare must be 'ge' or 'le'")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _roll_hits(force: Iterable[Dict[str, Union[str, int]]], role: str, catalog: Dict[str, UnitProfile], rng) -> int:
|
|
383
|
+
"""Roll all combat dice for one side in one combat round."""
|
|
384
|
+
hits = 0
|
|
385
|
+
for unit in force:
|
|
386
|
+
profile = catalog[str(unit["name"])]
|
|
387
|
+
target = profile.attack_target if role == "attack" else profile.defense_target
|
|
388
|
+
for _ in range(profile.dice_count):
|
|
389
|
+
roll = rng.randint(1, profile.dice_sides)
|
|
390
|
+
if _hit_success(roll, target, profile.compare):
|
|
391
|
+
hits += 1
|
|
392
|
+
return hits
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _apply_hits(
|
|
396
|
+
defender_force: List[Dict[str, Union[str, int]]],
|
|
397
|
+
hits: int,
|
|
398
|
+
casualty_order: List[str],
|
|
399
|
+
) -> List[Dict[str, Union[str, int]]]:
|
|
400
|
+
"""Apply incoming hits using an optimal defender casualty strategy.
|
|
401
|
+
|
|
402
|
+
Strategy (assumes rational defender allocation):
|
|
403
|
+
1) Remove fresh non-sustain units (base hp=1) by casualty order
|
|
404
|
+
2) If only sustain units remain, damage fresh ones (hp=2→1) first
|
|
405
|
+
3) Only destroy already-damaged sustain units (hp=1 damaged→0) as last resort
|
|
406
|
+
|
|
407
|
+
"""
|
|
408
|
+
order_idx = {name: i for i, name in enumerate(casualty_order)}
|
|
409
|
+
|
|
410
|
+
force = defender_force[:]
|
|
411
|
+
for _ in range(hits):
|
|
412
|
+
if not force:
|
|
413
|
+
break
|
|
414
|
+
|
|
415
|
+
# Priority 1: Remove fresh non-sustain units (max_hp=1, currently hp=1).
|
|
416
|
+
# These die immediately and cannot absorb a second hit.
|
|
417
|
+
fresh_nonsustain = [u for u in force if int(u["max_hp"]) == 1 and int(u["hp"]) == 1]
|
|
418
|
+
if fresh_nonsustain:
|
|
419
|
+
victim = min(fresh_nonsustain, key=lambda u: order_idx.get(str(u["name"]), 999))
|
|
420
|
+
force.remove(victim)
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
# Priority 2: Damage fresh sustain units (max_hp=2, currently hp=2).
|
|
424
|
+
# Spreading damage is better than destroying already-damaged units.
|
|
425
|
+
fresh_sustain = [u for u in force if int(u["max_hp"]) > 1 and int(u["hp"]) == int(u["max_hp"])]
|
|
426
|
+
if fresh_sustain:
|
|
427
|
+
victim = min(fresh_sustain, key=lambda u: order_idx.get(str(u["name"]), 999))
|
|
428
|
+
victim["hp"] = int(victim["hp"]) - 1
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
# Priority 3: Destroy already-damaged sustain units (hp=1, max_hp=2).
|
|
432
|
+
damaged_sustain = [u for u in force if int(u["max_hp"]) > 1 and int(u["hp"]) == 1]
|
|
433
|
+
if damaged_sustain:
|
|
434
|
+
victim = min(damaged_sustain, key=lambda u: order_idx.get(str(u["name"]), 999))
|
|
435
|
+
force.remove(victim)
|
|
436
|
+
continue
|
|
437
|
+
|
|
438
|
+
# Fallback (should not reach here if logic is sound).
|
|
439
|
+
force.pop(0)
|
|
440
|
+
|
|
441
|
+
return force
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _simulate_battle(
|
|
445
|
+
attacker_units: Dict[str, int],
|
|
446
|
+
defender_units: Dict[str, int],
|
|
447
|
+
catalog: Dict[str, UnitProfile],
|
|
448
|
+
casualty_order: List[str],
|
|
449
|
+
n_sim: int,
|
|
450
|
+
rounds: int | None,
|
|
451
|
+
seed: int | None,
|
|
452
|
+
) -> CombatResult:
|
|
453
|
+
"""Monte Carlo battle simulation for heterogeneous unit sets."""
|
|
454
|
+
if n_sim <= 0:
|
|
455
|
+
raise ValueError("n_sim must be > 0")
|
|
456
|
+
|
|
457
|
+
rng = random.Random(seed)
|
|
458
|
+
att_wins = 0
|
|
459
|
+
def_wins = 0
|
|
460
|
+
draws = 0
|
|
461
|
+
total_att_remaining = 0
|
|
462
|
+
total_def_remaining = 0
|
|
463
|
+
terminal_counts: Dict[Tuple[int, int], int] = {}
|
|
464
|
+
|
|
465
|
+
for _ in range(n_sim):
|
|
466
|
+
att_force = _build_force(attacker_units, catalog)
|
|
467
|
+
def_force = _build_force(defender_units, catalog)
|
|
468
|
+
|
|
469
|
+
round_idx = 0
|
|
470
|
+
while att_force and def_force and (rounds is None or round_idx < rounds):
|
|
471
|
+
round_idx += 1
|
|
472
|
+
att_hits = _roll_hits(att_force, "attack", catalog, rng)
|
|
473
|
+
def_hits = _roll_hits(def_force, "defense", catalog, rng)
|
|
474
|
+
|
|
475
|
+
def_force = _apply_hits(def_force, att_hits, casualty_order)
|
|
476
|
+
att_force = _apply_hits(att_force, def_hits, casualty_order)
|
|
477
|
+
|
|
478
|
+
att_left = len(att_force)
|
|
479
|
+
def_left = len(def_force)
|
|
480
|
+
total_att_remaining += att_left
|
|
481
|
+
total_def_remaining += def_left
|
|
482
|
+
terminal_counts[(att_left, def_left)] = terminal_counts.get((att_left, def_left), 0) + 1
|
|
483
|
+
|
|
484
|
+
if att_left > 0 and def_left == 0:
|
|
485
|
+
att_wins += 1
|
|
486
|
+
elif def_left > 0 and att_left == 0:
|
|
487
|
+
def_wins += 1
|
|
488
|
+
else:
|
|
489
|
+
draws += 1
|
|
490
|
+
|
|
491
|
+
p_att = att_wins / n_sim
|
|
492
|
+
p_def = def_wins / n_sim
|
|
493
|
+
p_draw = draws / n_sim
|
|
494
|
+
avg_att = total_att_remaining / n_sim
|
|
495
|
+
avg_def = total_def_remaining / n_sim
|
|
496
|
+
|
|
497
|
+
summary = (
|
|
498
|
+
f"Combat odds - attacker win: {p_att:.2%}, defender win: {p_def:.2%}, "
|
|
499
|
+
f"draw: {p_draw:.2%}, avg survivors (A/D): {avg_att:.2f}/{avg_def:.2f}"
|
|
500
|
+
)
|
|
501
|
+
detail = {
|
|
502
|
+
"method": "simulation",
|
|
503
|
+
"n_sim": n_sim,
|
|
504
|
+
"seed": seed,
|
|
505
|
+
"terminal_distribution": {
|
|
506
|
+
k: v / n_sim for k, v in sorted(terminal_counts.items(), key=lambda x: x[1], reverse=True)
|
|
507
|
+
},
|
|
508
|
+
}
|
|
509
|
+
return CombatResult(
|
|
510
|
+
attacker_wins=round(p_att, 6),
|
|
511
|
+
defender_wins=round(p_def, 6),
|
|
512
|
+
draw=round(p_draw, 6),
|
|
513
|
+
avg_att_remaining=round(avg_att, 3),
|
|
514
|
+
avg_def_remaining=round(avg_def, 3),
|
|
515
|
+
summary=summary,
|
|
516
|
+
detail=detail,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def ti_odds(
|
|
521
|
+
#validated using https://ti4battle.com/
|
|
522
|
+
attacker: Dict[str, int],
|
|
523
|
+
defender: Dict[str, int],
|
|
524
|
+
n_sim: int = 10_000,
|
|
525
|
+
rounds: int | None = None,
|
|
526
|
+
seed: int | None = None,
|
|
527
|
+
output: str = "summary",
|
|
528
|
+
):
|
|
529
|
+
"""Estimate Twilight Imperium 4 combat odds with simulation.
|
|
530
|
+
|
|
531
|
+
Parameters
|
|
532
|
+
----------
|
|
533
|
+
attacker, defender : dict
|
|
534
|
+
Unit dictionaries, e.g. {'Dreadnought': 1, 'Cruiser': 2, 'Fighter': 4}.
|
|
535
|
+
n_sim : int
|
|
536
|
+
Number of Monte Carlo simulations.
|
|
537
|
+
rounds : int or None
|
|
538
|
+
Optional max combat rounds. None means fight until one side is gone.
|
|
539
|
+
seed : int or None
|
|
540
|
+
Optional RNG seed for reproducibility.
|
|
541
|
+
output : str
|
|
542
|
+
One of 'summary', 'result', 'dict', 'dataframe'.
|
|
543
|
+
"""
|
|
544
|
+
casualty_order = ["Infantry", "Infantry+", "Fighter", "Fighter+", "Destroyer", "Destroyer+", "Cruiser", "Cruiser+", "Carrier", "Dreadnought", "War Sun"]
|
|
545
|
+
result = _simulate_battle(
|
|
546
|
+
attacker_units=attacker,
|
|
547
|
+
defender_units=defender,
|
|
548
|
+
catalog=TI4_UNITS,
|
|
549
|
+
casualty_order=casualty_order,
|
|
550
|
+
n_sim=n_sim,
|
|
551
|
+
rounds=rounds,
|
|
552
|
+
seed=seed,
|
|
553
|
+
)
|
|
554
|
+
result.summary = "TI4 " + result.summary
|
|
555
|
+
return _format_result(result, output)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def aa_odds(
|
|
559
|
+
attacker: Dict[str, int],
|
|
560
|
+
defender: Dict[str, int],
|
|
561
|
+
n_sim: int = 10_000,
|
|
562
|
+
rounds: int | None = None,
|
|
563
|
+
seed: int | None = None,
|
|
564
|
+
output: str = "summary",
|
|
565
|
+
):
|
|
566
|
+
"""Estimate Axis & Allies combat odds with simulation.
|
|
567
|
+
|
|
568
|
+
This model uses core attack/defense values and ignores special-case rules
|
|
569
|
+
(first strike, bombardment, transport restrictions, etc.).
|
|
570
|
+
"""
|
|
571
|
+
casualty_order = ["Infantry", "Infantry+", "Artillery", "Tank", "Fighter", "Bomber", "Destroyer", "Submarine", "Cruiser", "Carrier", "Battleship"]
|
|
572
|
+
result = _simulate_battle(
|
|
573
|
+
attacker_units=attacker,
|
|
574
|
+
defender_units=defender,
|
|
575
|
+
catalog=AA_UNITS,
|
|
576
|
+
casualty_order=casualty_order,
|
|
577
|
+
n_sim=n_sim,
|
|
578
|
+
rounds=rounds,
|
|
579
|
+
seed=seed,
|
|
580
|
+
)
|
|
581
|
+
result.summary = "Axis & Allies " + result.summary
|
|
582
|
+
return _format_result(result, output)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _coerce_risk_armies(value) -> int:
|
|
586
|
+
if isinstance(value, int):
|
|
587
|
+
return value
|
|
588
|
+
if isinstance(value, dict):
|
|
589
|
+
if "armies" in value:
|
|
590
|
+
return int(value["armies"])
|
|
591
|
+
if len(value) == 1:
|
|
592
|
+
return int(next(iter(value.values())))
|
|
593
|
+
return int(sum(int(v) for v in value.values()))
|
|
594
|
+
raise ValueError("For game='risk', attacker/defender should be int or dict with counts")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def combat_odds(
|
|
598
|
+
attacker,
|
|
599
|
+
defender,
|
|
600
|
+
game: str = "ti",
|
|
601
|
+
rounds: int | None = None,
|
|
602
|
+
n_sim: int = 10_000,
|
|
603
|
+
seed: int | None = None,
|
|
604
|
+
output: str = "summary",
|
|
605
|
+
):
|
|
606
|
+
"""Unified combat-odds entrypoint — routes to the right game automatically.
|
|
607
|
+
|
|
608
|
+
Parameters
|
|
609
|
+
----------
|
|
610
|
+
attacker, defender
|
|
611
|
+
For ``game='risk'``: plain integers (number of armies).
|
|
612
|
+
For ``game='ti'`` / ``game='axis & allies'``: dicts mapping unit name
|
|
613
|
+
to count, e.g. ``{'Cruiser': 2, 'Fighter': 4}``.
|
|
614
|
+
game : str
|
|
615
|
+
Which game rules to use. Accepted values (case-insensitive)::
|
|
616
|
+
|
|
617
|
+
'risk'
|
|
618
|
+
'ti' / 'ti4' / 'twilight imperium'
|
|
619
|
+
'axis & allies' / 'aa' / 'axis and allies'
|
|
620
|
+
|
|
621
|
+
rounds : int or None
|
|
622
|
+
Maximum number of combat rounds before stopping (TI / A&A only).
|
|
623
|
+
``None`` means fight to the end.
|
|
624
|
+
n_sim : int
|
|
625
|
+
Number of Monte Carlo simulations (TI / A&A only; default 10 000).
|
|
626
|
+
seed : int or None
|
|
627
|
+
Random seed for reproducible results (TI / A&A only).
|
|
628
|
+
output : str
|
|
629
|
+
``'summary'``, ``'result'``, ``'dict'``, or ``'dataframe'``.
|
|
630
|
+
|
|
631
|
+
Examples
|
|
632
|
+
--------
|
|
633
|
+
>>> combat_odds(10, 8, game='risk')
|
|
634
|
+
'Risk odds - attacker win: ..., avg survivors (A/D): ...'
|
|
635
|
+
|
|
636
|
+
>>> combat_odds({'Dreadnought': 1, 'Cruiser': 2}, {'Fighter': 5}, game='ti')
|
|
637
|
+
|
|
638
|
+
>>> combat_odds({'Infantry': 4, 'Tank': 2}, {'Infantry': 5}, game='axis & allies')
|
|
639
|
+
"""
|
|
640
|
+
game_key = game.strip().lower().replace("_", " ")
|
|
641
|
+
if game_key in {"risk"}:
|
|
642
|
+
a = _coerce_risk_armies(attacker)
|
|
643
|
+
d = _coerce_risk_armies(defender)
|
|
644
|
+
return risk_odds(a, d, output=output)
|
|
645
|
+
if game_key in {"ti", "ti4", "twilight imperium", "twilight imperium 4"}:
|
|
646
|
+
return ti_odds(attacker, defender, n_sim=n_sim, rounds=rounds, seed=seed, output=output)
|
|
647
|
+
if game_key in {"axis & allies", "axis and allies", "aa", "a&a"}:
|
|
648
|
+
return aa_odds(attacker, defender, n_sim=n_sim, rounds=rounds, seed=seed, output=output)
|
|
649
|
+
raise ValueError("game must be one of: 'risk', 'ti', 'axis & allies'")
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def plot_combat(result: CombatResult, show: bool = True):
|
|
653
|
+
"""Plot the outcome probabilities of a combat result as a bar chart.
|
|
654
|
+
|
|
655
|
+
Typical usage::
|
|
656
|
+
|
|
657
|
+
result = risk_odds(8, 6, output='result') # or ti_odds / aa_odds
|
|
658
|
+
plot_combat(result)
|
|
659
|
+
|
|
660
|
+
The chart shows three bars — attacker wins, defender wins, and draw — each
|
|
661
|
+
labelled with its probability. An annotation in the top-right corner shows
|
|
662
|
+
the average number of units each side has left at the end of the battle.
|
|
663
|
+
|
|
664
|
+
Parameters
|
|
665
|
+
----------
|
|
666
|
+
result : CombatResult
|
|
667
|
+
The :class:`CombatResult` object returned by any ``*_odds`` function
|
|
668
|
+
called with ``output='result'``.
|
|
669
|
+
show : bool
|
|
670
|
+
If ``True`` (default), call ``plt.show()`` immediately.
|
|
671
|
+
If ``False``, return the :class:`~matplotlib.figure.Figure` object
|
|
672
|
+
instead — useful for embedding the chart in a larger figure or saving
|
|
673
|
+
it to a file::
|
|
674
|
+
|
|
675
|
+
fig = plot_combat(result, show=False)
|
|
676
|
+
fig.savefig('combat.png')
|
|
677
|
+
|
|
678
|
+
Returns
|
|
679
|
+
-------
|
|
680
|
+
matplotlib.figure.Figure or None
|
|
681
|
+
The figure when *show* is ``False``, otherwise ``None``.
|
|
682
|
+
"""
|
|
683
|
+
labels = ["Attacker wins", "Defender wins", "Draw"]
|
|
684
|
+
values = [result.attacker_wins, result.defender_wins, result.draw]
|
|
685
|
+
colors = ["#2ecc71", "#e74c3c", "#f1c40f"]
|
|
686
|
+
|
|
687
|
+
fig, ax = plt.subplots(figsize=(7.5, 4.5))
|
|
688
|
+
ax.bar(labels, values, color=colors, edgecolor="white", linewidth=0.7)
|
|
689
|
+
ax.set_ylim(0, max(1.0, max(values) * 1.2 if values else 1.0))
|
|
690
|
+
ax.set_ylabel("Probability")
|
|
691
|
+
ax.set_title("Combat outcome odds", fontweight="bold")
|
|
692
|
+
|
|
693
|
+
for i, value in enumerate(values):
|
|
694
|
+
ax.text(i, value + 0.015, f"{value:.1%}", ha="center", va="bottom", fontsize=10)
|
|
695
|
+
|
|
696
|
+
ax.text(
|
|
697
|
+
0.99,
|
|
698
|
+
0.97,
|
|
699
|
+
f"Avg survivors (A/D): {result.avg_att_remaining:.2f}/{result.avg_def_remaining:.2f}",
|
|
700
|
+
transform=ax.transAxes,
|
|
701
|
+
ha="right",
|
|
702
|
+
va="top",
|
|
703
|
+
fontsize=9,
|
|
704
|
+
bbox={"boxstyle": "round,pad=0.3", "facecolor": "#f8f9fa", "edgecolor": "#7f8c8d", "alpha": 0.9},
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
fig.tight_layout()
|
|
708
|
+
if show:
|
|
709
|
+
plt.show()
|
|
710
|
+
return None
|
|
711
|
+
return fig
|
|
712
|
+
|
|
713
|
+
# To do:
|
|
714
|
+
# - add support for TI4 tech upgrades?
|
|
715
|
+
# - add support for order of battle phases (e.g. A&A first strike, TI4 anti-fighter barrage, etc.)
|