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