• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pronovic / apologies / 17222784979

25 Aug 2025 10:53PM UTC coverage: 82.922% (+0.1%) from 82.79%
17222784979

push

github

web-flow
Ruff linter fixes (#72)

235 of 286 new or added lines in 13 files covered. (82.17%)

2 existing lines in 1 file now uncovered.

908 of 1095 relevant lines covered (82.92%)

3.32 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

41.88
/src/apologies/simulation.py
1
# vim: set ft=python ts=4 sw=4 expandtab:
2
# ruff: noqa: T201
3

4
"""
5
Run a simulation to see how well different character input sources behave.
6
"""
7

8
import csv
4✔
9
import statistics
4✔
10
import typing
4✔
11
from collections.abc import Sequence
4✔
12
from itertools import combinations_with_replacement
4✔
13
from pathlib import Path
4✔
14

15
from arrow import Arrow
4✔
16
from arrow import now as arrow_now
4✔
17
from attrs import frozen
4✔
18

19
from apologies.engine import Character, Engine
4✔
20
from apologies.game import MAX_PLAYERS, MIN_PLAYERS, GameMode, Player
4✔
21
from apologies.source import CharacterInputSource
4✔
22
from apologies.util import ISO_TIMESTAMP_FORMAT
4✔
23

24
if typing.TYPE_CHECKING:
4✔
NEW
25
    from _csv import _writer
×
26

27
BASE_HEADERS = [
4✔
28
    "Scenario",
29
    "Mode",
30
    "Iterations",
31
    "Players",
32
    "Player 1",
33
    "Player 2",
34
    "Player 3",
35
    "Player 4",
36
    "Median Turns",
37
    "Overall Mean Turns",
38
    "Overall Median Duration (ms)",
39
    "Overall Mean Duration (ms)",
40
]
41

42
SOURCE_HEADERS = [
4✔
43
    "Median Turns",
44
    "Mean Turns",
45
    "Median Duration (ms)",
46
    "Mean Duration (ms)",
47
    "Wins",
48
    "Win %",
49
]
50

51

52
def _mean(data: Sequence[float]) -> float | None:
4✔
53
    """Calculate the mean rounded to 2 decimal places or return None if there is not any data."""
54
    return round(statistics.mean(data), 2) if data else None
×
55

56

57
def _median(data: Sequence[float]) -> float | None:
4✔
58
    """Calculate the median rounded to 2 decimal places or return None if there is not any data."""
59
    return round(statistics.median(data), 2) if data else None
×
60

61

62
@frozen
4✔
63
class _Result:
4✔
64
    """Result of a single game within a scenario."""
65

66
    start: Arrow
4✔
67
    stop: Arrow
4✔
68
    character: Character
4✔
69
    player: Player
4✔
70

71

72
@frozen
4✔
73
class _Statistics:
4✔
74
    """Scenario statistics for a source."""
75

76
    source: str | None
4✔
77
    median_turns: float | None
4✔
78
    mean_turns: float | None
4✔
79
    median_duration: float | None
4✔
80
    mean_duration: float | None
4✔
81
    wins: int
4✔
82
    win_percent: float
4✔
83

84
    @staticmethod
4✔
85
    def for_results(name: str | None, results: list[_Result]) -> "_Statistics":
4✔
86
        in_scope = [result for result in results if name is None or result.character.source.name == name]
×
87
        turns = [result.player.turns for result in in_scope]
×
88
        durations_ms = [(result.stop - result.start).microseconds / 1000 for result in in_scope]
×
89
        median_turns = _median(turns)
×
90
        mean_turns = _mean(turns)
×
91
        median_duration = _median(durations_ms)
×
92
        mean_duration = _mean(durations_ms)
×
93
        wins = len(in_scope)
×
94
        win_percent = 0.0 if len(results) == 0 else round(100.0 * (wins / len(results)), 1)
×
95
        return _Statistics(name, median_turns, mean_turns, median_duration, mean_duration, wins, win_percent)
×
96

97

98
@frozen
4✔
99
class _Analysis:
4✔
100
    """Groups together information analyzed for a scenario."""
101

102
    scenario: str
4✔
103
    mode: str
4✔
104
    iterations: int
4✔
105
    players: int
4✔
106
    playernames: list[str]
4✔
107
    overall_stats: _Statistics
4✔
108
    source_stats: dict[str, _Statistics]
4✔
109

110

111
# pylint: disable=too-many-positional-arguments
112
def _analyze_scenario(  # noqa: PLR0917,PLR0913
4✔
113
    scenario: int,
114
    mode: GameMode,
115
    iterations: int,
116
    players: int,
117
    sources: Sequence[CharacterInputSource],
118
    combination: Sequence[CharacterInputSource],
119
    results: list[_Result],
120
) -> _Analysis:
121
    """Analyze a scenario, generating data that can be written to the CSV file."""
122
    playernames = [source.name for source in combination] + [""] * (MAX_PLAYERS - len(combination))
×
123
    overall_stats = _Statistics.for_results(None, results)
×
NEW
124
    source_stats = {name: _Statistics.for_results(name, results) for name in sorted({source.name for source in sources})}
×
NEW
125
    return _Analysis(f"Scenario {scenario}", mode.name, iterations, players, playernames, overall_stats, source_stats)
×
126

127

128
def _write_header(csvwriter: "_writer", sources: list[CharacterInputSource]) -> None:
4✔
129
    """Write the header into the CSV file."""
130
    headers = BASE_HEADERS[:]
×
NEW
131
    for name in sorted({source.name for source in sources}):
×
132
        for column in SOURCE_HEADERS:
×
NEW
133
            headers += [f"{name} - {column}"]
×
134
    csvwriter.writerow(headers)
×
135

136

137
def _write_scenario(csvwriter: "_writer", analysis: _Analysis) -> None:
4✔
138
    """Write analysis results for a scenario into the CSV file."""
139
    row = [analysis.scenario, analysis.mode, analysis.iterations, analysis.players]
×
140
    row += analysis.playernames
×
141
    row += [
×
142
        analysis.overall_stats.median_turns,
143
        analysis.overall_stats.mean_turns,
144
        analysis.overall_stats.median_duration,
145
        analysis.overall_stats.mean_duration,
146
    ]
147
    for stats in analysis.source_stats.values():
×
148
        row += [stats.median_turns, stats.mean_turns, stats.median_duration, stats.mean_duration, stats.wins, stats.win_percent]
×
149
    csvwriter.writerow(row)
×
150

151

152
def _run_scenario(prefix: str, iterations: int, engine: Engine) -> list[_Result]:
4✔
153
    """Run a particular scenario, playing a game repeatedly for a set number of iterations."""
154
    results = []
×
NEW
155
    for i in range(iterations):
×
156
        print(" " * 100, end="\r", flush=True)
×
NEW
157
        print(f"{prefix}iteration {i}", end="\r", flush=True)
×
158
        start = arrow_now()
×
159
        engine.reset()
×
160
        engine.start_game()
×
161
        while not engine.completed:
×
162
            engine.play_next()
×
163
        stop = arrow_now()
×
NEW
164
        character, player = engine.winner()
×
165
        results.append(_Result(start, stop, character, player))
×
166
    return results
×
167

168

169
# pylint: disable=too-many-locals,line-too-long
170
def run_simulation(iterations: int, output: str, sources: list[CharacterInputSource]) -> None:
4✔
171
    """
172
    Run a simulation.
173

174
    Args:
175
        iterations(int): The number of iterations (number of times to play the game)
176
        output(str): Path to the output file to write
177
        sources(List[CharacterInputSource]): The source to use for each player in the game
178
    """
NEW
179
    with Path(output).open("w", newline="", encoding="utf-8") as csvfile:
×
180
        csvwriter = csv.writer(csvfile, quoting=csv.QUOTE_ALL)
×
181
        _write_header(csvwriter, sources)
×
182

183
        start = arrow_now()
×
NEW
184
        print(f"Starting simulation at {start.format(ISO_TIMESTAMP_FORMAT)}, using {iterations} iterations per scenario")
×
185

186
        scenario = 0
×
187
        results = []
×
188
        for mode in GameMode:
×
189
            for players in range(MIN_PLAYERS, MAX_PLAYERS + 1):
×
NEW
190
                for case, combination in enumerate(combinations_with_replacement(sources, players), start=0):
×
191
                    scenario += 1
×
NEW
192
                    prefix = f"Scenario {scenario}: {mode.name} mode with {players} players (case {case}): "
×
193
                    characters = [Character(name=source.name, source=source) for source in combination]
×
194
                    engine = Engine(mode=mode, characters=characters)
×
195
                    print(" " * 100, end="\r", flush=True)
×
NEW
196
                    print(f"{prefix}starting", end="\r", flush=True)
×
197
                    results = _run_scenario(prefix, iterations, engine)
×
NEW
198
                    print(f"{prefix}analyzing", end="\r", flush=True)
×
199
                    analysis = _analyze_scenario(scenario, mode, iterations, players, sources, combination, results)
×
NEW
200
                    print(f"{prefix}writing CSV", end="\r", flush=True)
×
201
                    _write_scenario(csvwriter, analysis)
×
NEW
202
                    print(f"{prefix}done", end="\r", flush=True)
×
203

204
        stop = arrow_now()
×
205
        print(" " * 100, end="\r", flush=True)
×
206
        duration = stop.humanize(start, only_distance=True)
×
207
        finished = stop.format(ISO_TIMESTAMP_FORMAT)
×
NEW
208
        print(f"Simulation completed after {duration} at {finished}")
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc