Coverage for backend/game.py: 54%
114 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-17 17:55 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-17 17:55 +0000
1import difflib
2import itertools
3import math
4import random
5import threading
6from collections import deque
8import structlog
10from .game_state import Lobby, Player, TaggedMessage
11from .models import (
12 Burger,
13 Chat,
14 DayEnd,
15 Drink,
16 GameEnd,
17 GameStart,
18 Message,
19 NewOrder,
20 Order,
21 OrderComponent,
22 OrderScore,
23 OrderSubmission,
24 PlayerJoin,
25 PlayerLeave,
26 Role,
27 RoleAssignment,
28 Side,
29)
31logger = structlog.stdlib.get_logger(__file__)
33BURGER_INGREDIENTS = ["Patty", "Lettuce", "Onion", "Tomato", "Ketchup", "Mustard", "Cheese"]
34DRINK_COLORS = ["Blue", "Red", "Yellow", "Orange", "Purple", "Green"]
35DRINK_SIZES = ["S", "M", "L"]
36SIDE_TYPES = ["Fries", "Onion Rings", "Mozzarella Sticks"]
38MESSAGES_PER_LOOP = 5
41class GameLoop:
42 """Implements game logic."""
44 def __init__(self, lobby: Lobby) -> None:
45 self.lobby = lobby
47 # Backend stores game score and day
48 # Acts as source of truth in case any messages fail to send
49 self.day = 1
50 self.score = 0
52 self.orders = get_orders(day=self.day, num_players=len(self.lobby.players))
53 self.order: Order
55 self.started = False
57 def run(self) -> None:
58 """
59 Main game loop.
61 Processes messages in a loop.
62 """
63 while True:
64 for message in itertools.islice(self.lobby.messages(), MESSAGES_PER_LOOP):
65 match message.data:
66 case GameStart():
67 self.start_game(message.id)
68 case GameEnd():
69 return
70 case PlayerJoin():
71 self.lobby.broadcast(Message(data=message.data), exclude=[message.id])
72 case PlayerLeave(id=id):
73 self.lobby.players.pop(id)
75 self.lobby.broadcast(Message(data=message.data))
76 case Chat():
77 self.typing_indicator(message)
78 case OrderComponent() as component:
79 self.manager.send(Message(data=component))
80 case OrderSubmission(order=order):
81 logger.debug("Received order.", order=order)
82 self.score += self.grade_order(order)
83 self.lobby.broadcast(Message(data=OrderScore(score=self.score)))
84 self.handle_next_order()
85 case _:
86 logger.warning("Unimplemented message.", message=message.data)
88 def start_game(self, id: str) -> None:
89 """
90 Start game and generate the first order.
92 Args:
93 id: The id of the player that started the game.
94 """
95 if self.started:
96 return
98 logger.debug("Starting game.")
100 self.started = True
101 self.lobby.open = False
103 self.assign_roles()
104 self.lobby.broadcast(Message(data=GameStart()), exclude=[id])
105 self.handle_next_order()
107 def assign_roles(self) -> None:
108 """Assign roles to players."""
109 roles = list(Role)[: len(self.lobby.players)]
110 random.shuffle(roles)
112 for player, role in zip(self.lobby.players.values(), roles, strict=False):
113 player.role = role
114 player.send(Message(data=RoleAssignment(role=role)))
116 def handle_next_order(self) -> None:
117 """Give manager next order."""
118 if len(self.orders) == 0:
119 self.handle_new_day()
120 self.orders = get_orders(day=self.day, num_players=len(self.lobby.players))
122 self.order = self.orders.pop()
123 logger.debug("Order sent.")
124 self.manager.send(Message(data=NewOrder(order=self.order)))
126 def handle_new_day(self) -> None:
127 """Update current day."""
128 self.day += 1
129 logger.debug("New day.", day=self.day)
130 self.assign_roles()
131 self.lobby.broadcast(Message(data=DayEnd(day=self.day)))
133 def grade_order(self, order: Order) -> int:
134 """
135 Grade order based on correctness.
137 See capstone-projects-2025-spring.github.io/aac-go-fish/docs/requirements/features-and-requirements#scoring
138 """
139 # Burgers are graded based on edit distance to the correct burger for up to 2 extra dollars + 3 base dollars
140 burger_score = 300
141 if order.burger is not None:
142 # this is never None, but the type checker doesn't know that
143 assert self.order.burger is not None
145 similarity = difflib.SequenceMatcher(None, order.burger.ingredients, self.order.burger.ingredients).ratio()
146 burger_score += int(200 * round(similarity, 2))
148 # Sides are graded based on completeness.
149 # Up to 2 extra dollars + 1 base dollar
150 side_score = 0
151 if self.order.side is not None:
152 side_score += 100
154 if self.order.side == order.side:
155 side_score += 200
157 # Drink attributes are equally weighted, with the fill percentage being
158 # graded on the square root of the error from the correct fill
159 # percentage. up to 2 bonus dollars + 2 base dollars
160 drink_score = 0
161 if not (self.order.drink is None or order.drink is None):
162 drink_score += 200
164 correct = (self.order.drink.size == order.drink.size) + (self.order.drink.color == order.drink.color)
165 drink_score += 50 * correct
167 drink_score += int(math.sqrt(1 - abs(1 - order.drink.fill / 100)) * 100)
169 return burger_score + side_score + drink_score
171 def typing_indicator(self, msg: TaggedMessage) -> None:
172 """Send an indicator that the manager is typing."""
173 self.lobby.broadcast(Message(data=msg.data), exclude=[msg.id])
175 @property
176 def manager(self) -> Player:
177 """The player with the manager role."""
178 return next(player for player in self.lobby.players.values() if player.role == Role.manager)
181def start_main_loop(lobby: Lobby) -> None:
182 """Start the main game loop."""
183 loop = GameLoop(lobby)
184 threading.Thread(target=loop.run).start()
187def get_orders(day: int, num_players: int) -> deque[Order]:
188 """Return a queue of orders for the next day."""
189 orders = deque()
190 for _ in range(_orders_on_day(day)):
191 orders.append(_generate_order(num_players))
192 return orders
195def _orders_on_day(day: int) -> int:
196 """Compute the number of orders on a given day."""
197 return day * 2 - 1
200def _generate_order(num_players: int) -> Order:
201 """Generate an order based on the number of players."""
202 order = Order(
203 burger=Burger(
204 ingredients=["Bottom Bun"] + random.choices(BURGER_INGREDIENTS, k=random.randint(3, 8)) + ["Top Bun"]
205 ),
206 drink=None,
207 side=None,
208 )
210 if num_players >= 3:
211 order.drink = Drink(color=random.choice(DRINK_COLORS), fill=0, size=random.choice(DRINK_SIZES))
213 if num_players >= 4:
214 order.side = Side(table_state=random.choice(SIDE_TYPES))
216 return order