Coverage for backend/game.py: 54%

114 statements  

« 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 

7 

8import structlog 

9 

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) 

30 

31logger = structlog.stdlib.get_logger(__file__) 

32 

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"] 

37 

38MESSAGES_PER_LOOP = 5 

39 

40 

41class GameLoop: 

42 """Implements game logic.""" 

43 

44 def __init__(self, lobby: Lobby) -> None: 

45 self.lobby = lobby 

46 

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 

51 

52 self.orders = get_orders(day=self.day, num_players=len(self.lobby.players)) 

53 self.order: Order 

54 

55 self.started = False 

56 

57 def run(self) -> None: 

58 """ 

59 Main game loop. 

60 

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) 

74 

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) 

87 

88 def start_game(self, id: str) -> None: 

89 """ 

90 Start game and generate the first order. 

91 

92 Args: 

93 id: The id of the player that started the game. 

94 """ 

95 if self.started: 

96 return 

97 

98 logger.debug("Starting game.") 

99 

100 self.started = True 

101 self.lobby.open = False 

102 

103 self.assign_roles() 

104 self.lobby.broadcast(Message(data=GameStart()), exclude=[id]) 

105 self.handle_next_order() 

106 

107 def assign_roles(self) -> None: 

108 """Assign roles to players.""" 

109 roles = list(Role)[: len(self.lobby.players)] 

110 random.shuffle(roles) 

111 

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))) 

115 

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)) 

121 

122 self.order = self.orders.pop() 

123 logger.debug("Order sent.") 

124 self.manager.send(Message(data=NewOrder(order=self.order))) 

125 

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))) 

132 

133 def grade_order(self, order: Order) -> int: 

134 """ 

135 Grade order based on correctness. 

136 

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 

144 

145 similarity = difflib.SequenceMatcher(None, order.burger.ingredients, self.order.burger.ingredients).ratio() 

146 burger_score += int(200 * round(similarity, 2)) 

147 

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 

153 

154 if self.order.side == order.side: 

155 side_score += 200 

156 

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 

163 

164 correct = (self.order.drink.size == order.drink.size) + (self.order.drink.color == order.drink.color) 

165 drink_score += 50 * correct 

166 

167 drink_score += int(math.sqrt(1 - abs(1 - order.drink.fill / 100)) * 100) 

168 

169 return burger_score + side_score + drink_score 

170 

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]) 

174 

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) 

179 

180 

181def start_main_loop(lobby: Lobby) -> None: 

182 """Start the main game loop.""" 

183 loop = GameLoop(lobby) 

184 threading.Thread(target=loop.run).start() 

185 

186 

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 

193 

194 

195def _orders_on_day(day: int) -> int: 

196 """Compute the number of orders on a given day.""" 

197 return day * 2 - 1 

198 

199 

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 ) 

209 

210 if num_players >= 3: 

211 order.drink = Drink(color=random.choice(DRINK_COLORS), fill=0, size=random.choice(DRINK_SIZES)) 

212 

213 if num_players >= 4: 

214 order.side = Side(table_state=random.choice(SIDE_TYPES)) 

215 

216 return order