Disclaimer
- I do not have the relevant Python version to test your code, in particular for the typing. Hence, I will review a modified version of your code without typing and will provide code without type hints.
- Your code looks nice and I do not have much to add so my comments will be mostly details.
Tests and input/output
The functions tournament
and tournament_2
perform many actions including:
- reading the input
- preprocessing the input
- computing a result based on this input
It may be a good idea to separate the concerns. This is particularly relevant for Advent of Code problems because:
- the input will be the same for 2 different problems to be solved. Hence, performing steps 1 and 2 just once make sense
- you are usually provided some test inputs that will probably not be written in a dedicated file.
Hence, one could easily write a function to preprocess the input:
# A guide is a list of pair of strings
def get_guide_from_str(string):
return [tuple(l.strip().split(" ")) for l in string.splitlines()]
use it to write a function performing the file reading operation:
def get_guide_from_file(file_path):
with open(file_path) as f:
return get_guide_from_str(f.read())
and use them, on both:
- the test input provided in some kind of unit-test
- the actual input
# Unit-test (written poorly with assert - use a proper unit-test framework)
guide = get_guide_from_str("""A Y
B X
C Z""")
assert tournament(guide) == 15
assert tournament_2(guide) == 12
# Actual problem
guide = get_guide_from_file("year2022_day2_input.txt")
print(tournament(guide))
print(tournament_2(guide))
At this stage, the main functions are defined as such:
def tournament(guide):
shape_choices = ((shape[me], shape[op]) for op, me in guide)
return sum(get_score(*round_) for round_ in shape_choices)
def tournament_2(guide):
secrets = ((shape[op], strategies[strategy]) for op, strategy in guide)
choices = ((choose_shape(op, strategy), op) for op, strategy in secrets)
return sum(itertools.starmap(get_score, choices))
Data structures
You are using nice data structures containing most of the information from the original problem. However, a few things could probably be improved:
we test if elements are in FirstWinOverTheSecond
. The current implementation relies on iterating over the (few) elements. We could use a more relevant data structure such as a set which perform the "contains" operation more quickly: FirstWinOverTheSecond = set(((Rock, Scissors), (Scissors, Paper), (Paper, Rock)))
.
maybe we could define a data structure which would contain even more information.
# `game_status[(a, b)]` is the result of the game from `b`'s point of view (it would probably make sense to write this the other way around but it's too late now)
game_status = dict()
for a, b in itertools.product(RPS, repeat=2):
result = None # Not needed
if a == b:
result = Draw
elif (a, b) in FirstWinOverTheSecond:
result = Lose
else:
assert (b, a) in FirstWinOverTheSecond
result = Win
game_status[(a, b)] = result
used with:
def get_score(me, opponent) -> int:
game_score = win_scoring[game_status[opponent, me]]
shape_score = shape_scoring[me]
return game_score + shape_score
This initialisation of game_status
could be shorten significantly:
game_status = dict()
for a in RPS:
game_status[(a, a)] = Draw
for a, b in FirstWinOverTheSecond:
game_status[(a, b)] = Lose
game_status[(b, a)] = Win
Then, we could (we do not have to but we could) get rid of FirstWinOverTheSecond and write the whole logic in terms of game_status
:
game_status = dict()
for a in RPS:
game_status[(a, a)] = Draw
for a, b in ((Rock, Scissors), (Scissors, Paper), (Paper, Rock)):
game_status[(a, b)] = Lose
game_status[(b, a)] = Win
# For second part
strategies = dict(zip("ZXY", WLD))
worst_choice_against = dict(k for k, v in game_status.items() if v == Lose)
best_choice_against = dict(k for k, v in game_status.items() if v == Win)
Ultimately, it appears that the second part could probably be reduced similarly to a request in a well-defined dictionnary:
- part 1 is about getting a game status based on 2 player inputs
- part 2 is about getting a player input based on a game status and a player input
We can write this as such:
# `shape_to_play[(a, res)]` is the action to perform against `a` to get result `res`
shape_to_play = {(a, res): b for (a, b), res in game_status.items()}
def tournament_2(guide):
strategies = dict(zip("ZXY", WLD))
secrets = ((shape[op], strategies[strategy]) for op, strategy in guide)
choices = ((shape_to_play[(op, strategy)], op) for op, strategy in secrets)
return sum(itertools.starmap(get_score, choices))
Cosmetic details
I find it slightly more pleasing to define "WLD" in a different order to have a more natural progression:
LDW = Lose, Draw, Win = "LDW"
...
win_scoring = dict(zip(LDW, (0, 3, 6)))
...
strategies = dict(zip("XYZ", LDW))
Computing what what is already known
Relying on get_score
which computes the game status based on hand shapes for the step 2 is a bit akward. For a reminder: we start from a game output, compute what to play from it and then compute the game output from what we play.
We could write smaller dedicated functions for step 1 and 2 and take this chance to simplify a bit the logic:
def score1(opponent, me) -> int:
return win_scoring[game_status[opponent, me]] + shape_scoring[me]
def tournament(guide):
return sum(score1(shape[op], shape[me]) for op, me in guide)
def score2(opponent, outcome):
return win_scoring[outcome] + shape_scoring[shape_to_play[opponent, outcome]]
def tournament_2(guide):
outcomes = dict(zip("XYZ", LDW))
return sum(score2(shape[op], outcomes[out]) for op, out in guide)
Final code
At the end, we get the following code. I'm impressed by how short it is. The dictionnary are defined in a very concise way and do all the heavy lifting.
import itertools
import pathlib
Shape: str
WinStatus: str
RPS = Rock, Paper, Scissors = "RPS"
LDW = Lose, Draw, Win = "LDW"
shape = dict(zip("ABCXYZ", (*RPS, *RPS)))
shape_scoring = dict(zip(RPS, (1, 2, 3)))
win_scoring = dict(zip(LDW, (0, 3, 6)))
# `game_status[(a, b)]` is the result of the game from `b`'s point of view (it would probably make sense to write this the other way around but it's too late now)
game_status = dict()
for a in RPS:
game_status[(a, a)] = Draw
for a, b in ((Rock, Scissors), (Scissors, Paper), (Paper, Rock)):
game_status[(a, b)] = Lose
game_status[(b, a)] = Win
# `shape_to_play[(a, res)]` is the action to perform against `a` to get result `res`
shape_to_play = {(a, res): b for (a, b), res in game_status.items()}
def score1(opponent, me) -> int:
return win_scoring[game_status[opponent, me]] + shape_scoring[me]
def tournament(guide):
return sum(score1(shape[op], shape[me]) for op, me in guide)
def score2(opponent, outcome):
return win_scoring[outcome] + shape_scoring[shape_to_play[opponent, outcome]]
def tournament_2(guide):
outcomes = dict(zip("XYZ", LDW))
return sum(score2(shape[op], outcomes[out]) for op, out in guide)
# A guide is a list of pair of strings
def get_guide_from_str(string):
return [tuple(l.strip().split(" ")) for l in string.splitlines()]
def get_guide_from_file(file_path):
with open(file_path) as f:
return get_guide_from_str(f.read())
# Unit-test (written poorly with assert - use a proper unit-test framework)
guide = get_guide_from_str("""A Y
B X
C Z""")
assert tournament(guide) == 15
assert tournament_2(guide) == 12
# Actual problem
guide = get_guide_from_file("year2022_day2_input.txt")
print(tournament(guide))
print(tournament_2(guide))
And now for something different
Instead of relying on data structures to handle most of the logic, another solution can be to rely on modular arithmetic. Indeed, assigning consecutive values to Rock, Paper and Scissors, one can get quickly the result for the game (a, b)
by computing (a - b) % 3
which gives a result being draw (0), loss (1) or win (2). Similarly, from a given player input and result, one can compute the other player input.