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

pronovic / apologies / 17198723432

25 Aug 2025 04:03AM UTC coverage: 82.743%. First build
17198723432

Pull #71

github

web-flow
Merge ad2637eb1 into 349a67557
Pull Request #71: Replace Pylint with Ruff linter

135 of 138 new or added lines in 9 files covered. (97.83%)

911 of 1101 relevant lines covered (82.74%)

3.31 hits per line

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

40.52
/src/apologies/simulation.py
1
# vim: set ft=python ts=4 sw=4 expandtab:
2
# pylint: disable=line-too-long:
3

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

8
from __future__ import annotations  # so we can return a type from one of its own methods
4✔
9

10
import csv
4✔
11
import statistics
4✔
12
from collections.abc import Sequence
4✔
13
from itertools import combinations_with_replacement
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 .engine import Character, Engine
4✔
20
from .game import MAX_PLAYERS, MIN_PLAYERS, GameMode, Player
4✔
21
from .source import CharacterInputSource
4✔
22
from .util import ISO_TIMESTAMP_FORMAT
4✔
23

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

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

48

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

53

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

58

59
@frozen
4✔
60
class _Result:
4✔
61
    """Result of a single game within a scenario."""
62

63
    start: Arrow
4✔
64
    stop: Arrow
4✔
65
    character: Character
4✔
66
    player: Player
4✔
67

68

69
@frozen
4✔
70
class _Statistics:
4✔
71
    """Scenario statistics for a source."""
72

73
    source: str | None
4✔
74
    median_turns: float | None
4✔
75
    mean_turns: float | None
4✔
76
    median_duration: float | None
4✔
77
    mean_duration: float | None
4✔
78
    wins: int
4✔
79
    win_percent: float
4✔
80

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

94

95
@frozen
4✔
96
class _Analysis:
4✔
97
    """Groups together information analyzed for a scenario."""
98

99
    scenario: str
4✔
100
    mode: str
4✔
101
    iterations: int
4✔
102
    players: int
4✔
103
    playernames: list[str]
4✔
104
    overall_stats: _Statistics
4✔
105
    source_stats: dict[str, _Statistics]
4✔
106

107

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

124

125
def _write_header(csvwriter, sources: list[CharacterInputSource]) -> None:  # type: ignore
4✔
126
    """Write the header into the CSV file."""
127
    headers = BASE_HEADERS[:]
×
128
    for name in sorted(list({source.name for source in sources})):
×
129
        for column in SOURCE_HEADERS:
×
130
            headers += ["%s - %s" % (name, column)]
×
131
    csvwriter.writerow(headers)
×
132

133

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

148

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

165

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

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

180
        start = arrow_now()
×
181
        print("Starting simulation at %s, using %d iterations per scenario" % (start.format(ISO_TIMESTAMP_FORMAT), iterations))
×
182

183
        scenario = 0
×
184
        results = []
×
185
        for mode in GameMode:
×
186
            for players in range(MIN_PLAYERS, MAX_PLAYERS + 1):
×
187
                case = 0
×
188
                for combination in combinations_with_replacement(sources, players):
×
189
                    case += 1
×
190
                    scenario += 1
×
191
                    prefix = "Scenario %d: %s mode with %d players (case %d): " % (scenario, mode.name, players, case)
×
192
                    characters = [Character(name=source.name, source=source) for source in combination]
×
193
                    engine = Engine(mode=mode, characters=characters)
×
194
                    print(" " * 100, end="\r", flush=True)
×
195
                    print("%sstarting" % prefix, end="\r", flush=True)
×
196
                    results = _run_scenario(prefix, iterations, engine)
×
197
                    print("%sanalyzing" % prefix, end="\r", flush=True)
×
198
                    analysis = _analyze_scenario(scenario, mode, iterations, players, sources, combination, results)
×
199
                    print("%swriting CSV" % prefix, end="\r", flush=True)
×
200
                    _write_scenario(csvwriter, analysis)
×
201
                    print("%sdone" % prefix, end="\r", flush=True)
×
202

203
        stop = arrow_now()
×
204
        print(" " * 100, end="\r", flush=True)
×
205
        duration = stop.humanize(start, only_distance=True)
×
206
        finished = stop.format(ISO_TIMESTAMP_FORMAT)
×
207
        print("Simulation completed after %s at %s" % (duration, 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