0
\$\begingroup\$

Advent of code, Day 2.

Here is my code.

Less verbose version of the question follows.

import itertools
import pathlib
from typing import TypeAlias

Shape: TypeAlias = str
WinStatus: TypeAlias = str

RPS = Rock, Paper, Scissors = "RPS"
WLD = Win, Lose, Draw = "WLD"
shape = dict(zip("ABCXYZ", (*RPS, *RPS), strict=True))
shape_scoring = dict(zip(RPS, (1, 2, 3), strict=True))
win_scoring = dict(zip(WLD, (6, 0, 3), strict=True))
FirstWinOverTheSecond = ((Rock, Scissors), (Scissors, Paper), (Paper, Rock))

# For second part
strategies = dict(zip("ZXY", WLD, strict=True))
worst_choice_against = dict(FirstWinOverTheSecond)
best_choice_against = {v: k for k, v in worst_choice_against.items()}


def round_outcome(me: Shape, opponent: Shape) -> WinStatus:
    if me == opponent:
        return Draw

    elif (me, opponent) in FirstWinOverTheSecond:
        return Win
    elif (opponent, me) in FirstWinOverTheSecond:
        return Lose

    assert False, "There should be no round like ({me=}, {opponent=})"


def get_score(me: Shape, opponent: Shape) -> int:
    game_score = win_scoring[round_outcome(me, opponent)]
    shape_score = shape_scoring[me]
    return game_score + shape_score


def tournament(f: pathlib.Path):
    rounds = (line.split() for line in f.read_text().splitlines())
    shape_choices = ((shape[me], shape[op]) for op, me in rounds)
    return sum(get_score(*round_) for round_ in shape_choices)


def choose_shape(opponent: Shape, strategy: WinStatus) -> Shape:
    if strategy == Draw:
        return opponent
    elif strategy == Win:
        return best_choice_against[opponent]
    elif strategy == Lose:
        return worst_choice_against[opponent]

    assert False, "There should be no round like ({opponent=}, {strategy=})"


def tournament_2(f: pathlib.Path):
    rounds = (line.split() for line in f.read_text().splitlines())
    secrets = ((shape[op], strategies[strategy]) for op, strategy in rounds)
    choices = ((choose_shape(op, strategy), op) for op, strategy in secrets)
    return sum(itertools.starmap(get_score, choices))

Input

A file that contains 2 letters in each line:

C X
C Y
C X
B X
B Z
A Z
[...]

Output:

Total score of player 1 when playing according to the rules.


Rules

Part 1

Player 1 playing guide:

  • X means Rock
  • Y means Paper
  • Z means Scissors

Player 2 playing guide:

  • A is for Rock
  • B is for Paper
  • C is for Scissors

Part 2

Player 1 playing guide:

  • X means "You should lose"
  • Y means "You should make the game ending as a draw"
  • Z means "You should win"

Player 2 playing guide:

  • A is for Rock
  • B is for Paper
  • C is for Scissors

Scoring system

In each round:

  • 0 for losing, 3 for draw, 6 for winning.
  • Additional 1 for choosing rock, 2 for choosing paper, 3 for choosing scissors.
\$\endgroup\$
1
  • 1
    \$\begingroup\$ I changed the title so that it describes what the code does rather than where you found the challenge. \$\endgroup\$ Dec 2, 2022 at 10:49

1 Answer 1

4
\$\begingroup\$

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:

  1. reading the input
  2. preprocessing the input
  3. 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.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.