From 59ec7ad7292b39241198f8f0b5287155f23c1b76 Mon Sep 17 00:00:00 2001 From: Bryan Collazo Date: Mon, 30 Mar 2026 19:46:04 -0400 Subject: [PATCH 1/6] Make HomePage mobile-friendly with proper scrolling (#373) * Make HomePage mobile-friendly with proper scrolling The page was unscrollable on mobile because html/body/#root have overflow:hidden, and justify-content:center on a fixed-height flex container clips overflowing content at the top (unreachable). Fix by adding overflow-y:auto to .home-page and replacing justify-content:center with ::before/::after flex spacers. This centers content when it fits the viewport, but falls back to top-aligned natural flow with scrolling when content overflows. Also keep the player-row 3-column layout on mobile instead of collapsing to single column. https://claude.ai/code/session_01Tpb9oUh1grX3WDUngr3nmJ * Make it Mobile Friendly * Namespace CSS Better * Default Params for Competitive 1v1 --------- Co-authored-by: Claude --- ui/src/pages/HomePage.scss | 480 ++++++++++++++++++++----------------- ui/src/pages/HomePage.tsx | 310 ++++++++++++------------ 2 files changed, 416 insertions(+), 374 deletions(-) diff --git a/ui/src/pages/HomePage.scss b/ui/src/pages/HomePage.scss index a8392d63..be18626f 100644 --- a/ui/src/pages/HomePage.scss +++ b/ui/src/pages/HomePage.scss @@ -3,275 +3,321 @@ .home-page { height: 100%; - padding: 24px; - box-sizing: border-box; + min-height: 100%; + min-height: 100dvh; + overflow-y: auto; + overflow-x: hidden; + padding: 24px 16px 0; background: radial-gradient(circle at top, rgb(34 120 255 / 18%), transparent 35%), linear-gradient(180deg, #070a0c 0%, #111614 100%); +} + +.home-page__inner { + width: min(100%, 680px); + margin: 0 auto; +} + +.home-page .logo { + margin: 12px 0 24px; + color: white; + font-size: clamp(1.9rem, 5vw, 2.4rem); + text-align: center; +} + +.home-page .setup-card { + width: 100%; + padding: 18px 20px 28px; + border: 1px solid rgb(255 255 255 / 8%); + border-radius: 24px; + background: rgb(21 19 19 / 92%); + box-shadow: 0 20px 60px rgb(0 0 0 / 28%); +} + +.home-page .setup-note { + margin: 0 0 14px; + color: rgb(255 255 255 / 72%); + font-size: 0.73rem; + letter-spacing: 0.08em; + text-align: center; + text-transform: uppercase; +} + +.home-page .control-group { + margin-bottom: 16px; +} + +.home-page .control-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.home-page .compact-control { + margin-bottom: 0; +} +.home-page .control-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + color: white; +} + +.home-page .control-header strong { + font-size: 0.9rem; + letter-spacing: 0.04em; + line-height: 1; +} + +.home-page .map-template-buttons { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.home-page .players-alert { + margin-bottom: 10px; +} + +.home-page .players-list { display: flex; flex-direction: column; - justify-content: center; + gap: 8px; + margin-bottom: 10px; +} + +.home-page .player-row { + display: grid; + grid-template-columns: 96px minmax(0, 1fr) auto; + gap: 10px; align-items: center; - min-height: 100vh; +} - h1 { - color: white; - font-size: 2.2rem; - margin-bottom: 14px; - } +.home-page .player-meta { + display: flex; + flex-direction: column; + gap: 3px; +} - .switchable { - width: min(100%, 680px); - min-height: 0; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; +.home-page .player-label { + color: white; + font-size: 0.9rem; +} - .MuiButton-root { - margin-bottom: variables.$sm-gutter; - } - } +.home-page .player-color-chip { + display: inline-flex; + width: fit-content; + padding: 2px 7px; + border-radius: 999px; + color: rgb(255 255 255 / 92%); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.05em; + background: rgb(255 255 255 / 10%); +} - .setup-card { - width: 100%; - background: rgb(21 19 19 / 92%); - border: 1px solid rgb(255 255 255 / 8%); - border-radius: 24px; - padding: 18px 20px; - box-shadow: 0 20px 60px rgb(0 0 0 / 28%); - } +.home-page .player-color-chip.red { + background: rgb(254 4 0 / 22%); + color: #ff8a86; +} - .setup-note { - color: rgb(255 255 255 / 72%); - text-transform: uppercase; - letter-spacing: 0.08em; - font-size: 0.73rem; - text-align: center; - margin: 0 0 14px; - } +.home-page .player-color-chip.blue { + background: rgb(34 120 255 / 22%); + color: #8cb7ff; +} - .control-group { - margin-bottom: 16px; - } +.home-page .player-color-chip.orange { + background: rgb(255 165 0 / 24%); + color: #ffd18a; +} - .control-row { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 14px; - } +.home-page .player-color-chip.white { + background: rgb(255 255 255 / 16%); + color: rgb(255 255 255 / 88%); +} - .compact-control { - margin-bottom: 0; - } +.home-page .switch-control { + display: flex; + flex-direction: column; +} - .control-header { - display: flex; - justify-content: space-between; - align-items: center; - color: white; - margin-bottom: 8px; - min-height: 24px; - - strong { - font-size: 0.9rem; - letter-spacing: 0.04em; - line-height: 1; - } - } +.home-page .inline-title { + display: inline-flex; + align-items: center; + gap: 4px; +} - .map-template-buttons { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - } +.home-page .MuiButton-root.choice-button, +.home-page .MuiButton-root.add-player-btn { + color: white; + background-color: variables.$dark-gray; +} - .MuiButton-root.choice-button, - .MuiButton-root.add-player-btn, - .MuiButton-root.start-btn { - color: white; - background-color: variables.$dark-gray; +.home-page .MuiButton-root.choice-button:hover, +.home-page .MuiButton-root.add-player-btn:hover { + background-color: color.adjust(variables.$dark-gray, $lightness: -8%); +} - &:hover { - background-color: color.adjust(variables.$dark-gray, $lightness: -8%); - } - } +.home-page .MuiButton-root.choice-button.selected { + background-color: #0d47a1; +} - .MuiButton-root.choice-button.selected { - background-color: #0d47a1; +.home-page .MuiButton-root.choice-button.selected:hover { + background-color: color.adjust(#0d47a1, $lightness: -2%); +} - &:hover { - background-color: color.adjust(#0d47a1, $lightness: -2%); - } - } +.home-page .MuiButton-root.remove-player-btn { + min-width: 80px; + color: rgb(255 255 255 / 75%); +} - .players-list { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 10px; - } +.home-page .MuiButton-root.remove-player-btn.Mui-disabled { + color: rgb(255 255 255 / 30%); +} - .players-heading { - display: inline-flex; - align-items: baseline; - gap: 6px; - } +.home-page .add-player-btn, +.home-page .start-btn { + width: 100%; +} - .players-hint { - color: rgb(255 255 255 / 48%); - font-size: 0.72rem; - font-weight: 400; - letter-spacing: 0.01em; - } +.home-page .start-btn { + height: 40px; + color: white; + font-weight: 700; + letter-spacing: 0.08em; + background: linear-gradient(135deg, #2f87ff 0%, #0d47a1 100%); + box-shadow: 0 12px 28px rgb(13 71 161 / 32%); +} - .players-alert { - margin-bottom: 10px; - } +.home-page .start-btn:hover { + background: linear-gradient(135deg, #4b98ff 0%, #1565c0 100%); + box-shadow: 0 14px 30px rgb(21 101 192 / 36%); +} - .player-row { - display: grid; - grid-template-columns: minmax(78px, auto) minmax(0, 1fr) auto; - gap: 10px; - align-items: center; - } +.home-page .MuiInputBase-root { + width: 100%; + min-width: 0; + color: white; + background: rgb(255 255 255 / 4%); +} - .player-meta { - display: flex; - flex-direction: column; - gap: 3px; - } +.home-page .MuiInputBase-root.MuiOutlinedInput-root, +.home-page .MuiInputBase-root.MuiOutlinedInput-root .MuiSelect-select, +.home-page .MuiInputBase-root.MuiOutlinedInput-root .MuiSelect-select span, +.home-page .MuiInputBase-root.MuiOutlinedInput-root input { + color: white; +} - .player-label { - color: white; - font-size: 0.9rem; - } +.home-page .MuiInputBase-root.MuiOutlinedInput-root .MuiSvgIcon-root, +.home-page .MuiSelect-icon { + color: rgb(255 255 255 / 72%); +} - .player-color-chip { - display: inline-flex; - align-items: center; - width: fit-content; - padding: 2px 7px; - border-radius: 999px; - font-size: 0.65rem; - font-weight: 700; - letter-spacing: 0.05em; - color: rgb(255 255 255 / 92%); - background: rgb(255 255 255 / 10%); - - &.red { - background: rgb(254 4 0 / 22%); - color: #ff8a86; - } - - &.blue { - background: rgb(34 120 255 / 22%); - color: #8cb7ff; - } - - &.orange { - background: rgb(255 165 0 / 24%); - color: #ffd18a; - } - - &.white { - background: rgb(255 255 255 / 16%); - color: rgb(255 255 255 / 88%); - } - } +.home-page .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline { + border-color: rgb(255 255 255 / 14%); +} - .MuiInputBase-root { - color: white; - background: rgb(255 255 255 / 4%); - } +.home-page .MuiSvgIcon-root, +.home-page .MuiSlider-root { + color: variables.$blue; +} - .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline { - border-color: rgb(255 255 255 / 14%); - } +.home-page .MuiIconButton-root.help-button { + margin: 0; + padding: 0; + color: rgb(255 255 255 / 52%); + font-size: 0.95rem; +} - .MuiSvgIcon-root, - .MuiSlider-root { - color: variables.$blue; - } +.home-page .MuiIconButton-root.help-button:hover { + color: rgb(255 255 255 / 82%); + background: transparent; +} - .switch-control { - display: flex; - flex-direction: column; - } +.home-page .MuiCheckbox-root.inline-switch { + margin-left: -9px; + padding: 0; +} - .inline-title { - display: inline-flex; - align-items: center; - gap: 4px; - line-height: 1; - } +.home-page .MuiSlider-mark { + background-color: rgb(255 255 255 / 20%); +} - .MuiIconButton-root.help-button { - color: rgb(255 255 255 / 52%); - padding: 0; - font-size: 0.95rem; - line-height: 1; - margin-bottom: 0; - - &:hover { - color: rgb(255 255 255 / 82%); - background: transparent; - } +.home-page .loader { + display: block; + margin: 40px auto 0; +} + +.home-page .page-end-spacer { + height: max(32px, calc(env(safe-area-inset-bottom) + 24px)); +} + +@media (max-width: variables.$sm-breakpoint) { + .home-page { + padding: 24px 16px 0; } - .MuiCheckbox-root.inline-switch { - margin-left: -9px; - padding: 0; + .home-page .logo { + margin: 16px 0 20px; + font-size: clamp(1.75rem, 8vw, 2.2rem); } - .MuiSlider-mark { - background-color: rgb(255 255 255 / 20%); + .home-page .setup-card { + padding: 16px 16px 24px; + border-radius: 18px; } - .MuiButton-root.remove-player-btn { - color: rgb(255 255 255 / 75%); - min-width: 80px; + .home-page .map-template-buttons, + .home-page .control-row { + grid-template-columns: 1fr; + } - &.Mui-disabled { - color: rgb(255 255 255 / 30%); - } + .home-page .control-row { + gap: 0; } - .add-player-btn, - .start-btn { - width: 100%; + .home-page .map-template-buttons { + grid-template-columns: repeat(3, minmax(0, 1fr)); } - .start-btn { - height: 40px; - font-size: 0.95rem; + .home-page .compact-control { + margin-bottom: 16px; } +} - @media (max-width: variables.$sm-breakpoint) { - padding: 18px; +@media (max-width: 420px) { + .home-page { + padding: 20px 14px 0; + } - .setup-card { - padding: 16px; - border-radius: 18px; - } + .home-page .setup-card { + padding: 14px 14px 22px; + } - .map-template-buttons, - .player-row { - grid-template-columns: 1fr; - } + .home-page .control-header { + align-items: flex-start; + flex-wrap: wrap; + gap: 8px; + } - .control-row { - grid-template-columns: 1fr; - gap: 0; - } + .home-page .player-row { + grid-template-columns: 88px minmax(0, 1fr) auto; + gap: 8px; + padding: 0; + border-top: 0; + align-items: center; + } - .compact-control { - margin-bottom: 16px; - } + .home-page .MuiButton-root.remove-player-btn { + width: auto; + min-width: 80px; + } - .remove-player-btn { - justify-self: start; - } + .home-page .inline-title { + align-items: flex-start; + line-height: 1.2; } } diff --git a/ui/src/pages/HomePage.tsx b/ui/src/pages/HomePage.tsx index 9b9ec0ca..e5821641 100644 --- a/ui/src/pages/HomePage.tsx +++ b/ui/src/pages/HomePage.tsx @@ -36,12 +36,12 @@ const PLAYER_COLORS = ["RED", "BLUE", "ORANGE", "WHITE"] as const; export default function HomePage() { const [loading, setLoading] = useState(false); const [mapTemplate, setMapTemplate] = useState("BASE"); - const [vpsToWin, setVpsToWin] = useState(10); - const [discardLimit, setDiscardLimit] = useState(7); - const [friendlyRobber, setFriendlyRobber] = useState(false); + const [vpsToWin, setVpsToWin] = useState(15); + const [discardLimit, setDiscardLimit] = useState(9); + const [friendlyRobber, setFriendlyRobber] = useState(true); const [players, setPlayers] = useState([ + "HUMAN", "CATANATRON", - "RANDOM", ]); const navigate = useNavigate(); const humanCount = players.filter((player) => player === "HUMAN").length; @@ -96,180 +96,175 @@ export default function HomePage() { return (
-

Catanatron

- -
+
+

Catanatron

{!loading ? ( - <> -
-

Open hands. Random discard choice.

+
+

Open hands. Random discard choice.

-
-
- Map Template - {mapTemplate} -
-
- {MAP_TEMPLATES.map((value) => ( - - ))} -
+
+
+ Map Template + {mapTemplate}
+
+ {MAP_TEMPLATES.map((value) => ( + + ))} +
+
-
-
-
- Points to Win - {vpsToWin} -
- setVpsToWin(value as number)} - /> -
- -
-
- Card Discard Limit - {discardLimit} -
- setDiscardLimit(value as number)} - /> +
+
+
+ Points to Win + {vpsToWin}
+ setVpsToWin(value as number)} + /> +
-
-
- - Friendly Robber - - - - - - - {friendlyRobber ? "On" : "Off"} -
- - setFriendlyRobber(event.target.checked) - } - /> +
+
+ Card Discard Limit + {discardLimit}
+ setDiscardLimit(value as number)} + />
-
+
- - Players - (At most one Human player) - - {players.length}/4 -
- {hasTooManyHumans && ( - - Only one Human player is allowed. - - )} -
- {players.map((player, index) => ( -
-
- Player {index + 1} - - {PLAYER_COLORS[index]} - -
- - -
- ))} + + + + + {friendlyRobber ? "On" : "Off"}
+ + setFriendlyRobber(event.target.checked) + } + /> +
+
- +
+
+ Players + {players.length}/4 +
+ {hasTooManyHumans && ( + + Only one Human player is allowed. + + )} +
+ {players.map((player, index) => ( +
+
+ Player {index + 1} + + {PLAYER_COLORS[index]} + +
+ + +
+ ))}
- + + +
) : ( )} +
); From 7d2dea3d744ccfd8d9166b2646dba50ebd201f0e Mon Sep 17 00:00:00 2001 From: Bryan Collazo Date: Mon, 30 Mar 2026 20:09:27 -0400 Subject: [PATCH 2/6] Simplify DISCARD_RESOURCE value to a single Resource --- catanatron/catanatron/apply_action.py | 67 ++++++++----------- .../catanatron/gym/envs/action_space.py | 6 +- catanatron/catanatron/json.py | 2 - catanatron/catanatron/models/actions.py | 2 +- catanatron/catanatron/models/enums.py | 2 +- catanatron/catanatron/state.py | 4 +- tests/models/test_actions.py | 6 +- tests/test_json.py | 6 -- tests/test_state.py | 36 +++++++++- 9 files changed, 71 insertions(+), 60 deletions(-) diff --git a/catanatron/catanatron/apply_action.py b/catanatron/catanatron/apply_action.py index b1269e85..46c73951 100644 --- a/catanatron/catanatron/apply_action.py +++ b/catanatron/catanatron/apply_action.py @@ -94,7 +94,7 @@ def apply_action( elif action.action_type == ActionType.ROLL: action_record = apply_roll(state, action, action_record) elif action.action_type == ActionType.DISCARD_RESOURCE: - action_record = apply_discard(state, action, action_record) + action_record = apply_discard(state, action) elif action.action_type == ActionType.MOVE_ROBBER: action_record = apply_move_robber(state, action, action_record) elif action.action_type == ActionType.PLAY_KNIGHT_CARD: @@ -266,26 +266,26 @@ def apply_roll(state: State, action: Action, action_record=None): action = Action(action.color, action.action_type, dices) if number == 7: - state.discard_counts = { - color: ( + state.discard_counts = [ + ( player_num_resource_cards(state, color) // 2 if player_num_resource_cards(state, color) > state.discard_limit else 0 ) for color in state.colors - } - should_enter_discarding_sequence = any(state.discard_counts.values()) + ] + should_enter_discarding_sequence = any( + count > 0 for count in state.discard_counts + ) if should_enter_discarding_sequence: state.current_player_index = next( - i - for i, color in enumerate(state.colors) - if state.discard_counts[color] > 0 + i for i, count in enumerate(state.discard_counts) if count > 0 ) state.current_prompt = ActionPrompt.DISCARD state.is_discarding = True else: - state.discard_counts = {color: 0 for color in state.colors} + state.discard_counts = [0] * len(state.colors) # state.current_player_index stays the same state.current_prompt = ActionPrompt.MOVE_ROBBER state.is_moving_knight = True @@ -304,55 +304,42 @@ def apply_roll(state: State, action: Action, action_record=None): return ActionRecord(action=action, result=dices) -def normalize_discarded_cards(state: State, action: Action, action_record=None): +def apply_discard(state: State, action: Action): discarded = action.value - if discarded is None and action_record is not None: - discarded = action_record.result - - if discarded is None: - raise ValueError("Discard action requires a resource") - - if isinstance(discarded, (list, tuple)): - if len(discarded) != 1: - raise ValueError("Discard action must specify exactly 1 resource") - return discarded[0] - - return discarded - - -def apply_discard(state: State, action: Action, action_record=None): - discarded = normalize_discarded_cards(state, action, action_record) - remaining = state.discard_counts[action.color] - if remaining <= 0: - raise ValueError("Trying to discard more cards than required") + player_index = state.color_to_index[action.color] + remaining = state.discard_counts[player_index] + assert remaining > 0, "Trying to discard when not required" to_discard = freqdeck_from_listdeck([discarded]) - player_freqdeck_subtract(state, action.color, to_discard) state.resource_freqdeck = freqdeck_add(state.resource_freqdeck, to_discard) - state.discard_counts[action.color] = remaining - 1 + state.discard_counts[player_index] -= 1 action = Action(action.color, action.action_type, discarded) - if state.discard_counts[action.color] > 0: + if state.discard_counts[player_index] > 0: # state.current_player_index stays the same # state.current_prompt stays the same pass else: - discarders_left = [state.discard_counts[color] > 0 for color in state.colors][ - state.current_player_index + 1 : - ] - if any(discarders_left): - to_skip = discarders_left.index(True) - state.current_player_index = state.current_player_index + 1 + to_skip + next_discarder_index = next( + ( + i + for i in range(state.current_player_index + 1, len(state.colors)) + if state.discard_counts[i] > 0 + ), + None, + ) + if next_discarder_index is not None: + state.current_player_index = next_discarder_index # state.current_prompt stays the same else: state.current_player_index = state.current_turn_index state.current_prompt = ActionPrompt.MOVE_ROBBER state.is_discarding = False state.is_moving_knight = True - state.discard_counts = {color: 0 for color in state.colors} + state.discard_counts = [0] * len(state.colors) - return ActionRecord(action=action, result=[discarded]) + return ActionRecord(action=action, result=discarded) def apply_move_robber(state: State, action: Action, action_record=None): diff --git a/catanatron/catanatron/gym/envs/action_space.py b/catanatron/catanatron/gym/envs/action_space.py index 099b1de8..08f1c457 100644 --- a/catanatron/catanatron/gym/envs/action_space.py +++ b/catanatron/catanatron/gym/envs/action_space.py @@ -15,6 +15,8 @@ def get_action_array( catan_map = build_map(map_type) num_nodes = len(catan_map.land_nodes) + # We sort the actions to ensure a consistent ordering and reproducibility + # without sorting, we couldn't get gym usages to be reproducible actions_array = sorted( [ (ActionType.ROLL, None), @@ -67,9 +69,7 @@ def get_action_array( ], (ActionType.END_TURN, None), ], - # Preserve historical DISCARD ordering so the rename does not reshuffle - # integer action ids for gym consumers. - key=lambda action: str(action).replace("DISCARD_RESOURCE", "DISCARD"), + key=lambda action: str(action), ) return actions_array diff --git a/catanatron/catanatron/json.py b/catanatron/catanatron/json.py index 28ceb113..9d9756d4 100644 --- a/catanatron/catanatron/json.py +++ b/catanatron/catanatron/json.py @@ -30,8 +30,6 @@ def action_from_json(data) -> Action: if len(resources) not in [1, 2]: raise ValueError("Year of Plenty action must have 1 or 2 resources") action = Action(color, action_type, resources) - elif action_type == ActionType.DISCARD_RESOURCE: - action = Action(color, action_type, data[2]) elif action_type == ActionType.MOVE_ROBBER: coordinate, victim = data[2] coordinate = tuple(coordinate) diff --git a/catanatron/catanatron/models/actions.py b/catanatron/catanatron/models/actions.py index 74a4d171..cd0a6d95 100644 --- a/catanatron/catanatron/models/actions.py +++ b/catanatron/catanatron/models/actions.py @@ -279,7 +279,7 @@ def initial_road_possibilities(state, color) -> List[Action]: def discard_possibilities(state: State, color) -> List[Action]: - if state.discard_counts[color] <= 0: + if state.discard_counts[state.color_to_index[color]] <= 0: return [] return [ diff --git a/catanatron/catanatron/models/enums.py b/catanatron/catanatron/models/enums.py index 2940e84c..5b6c05d4 100644 --- a/catanatron/catanatron/models/enums.py +++ b/catanatron/catanatron/models/enums.py @@ -131,7 +131,7 @@ def __repr__(self): The "result" field is polymorphic depending on the action_type. - ROLL: result is (int, int) 2 dice rolled -- DISCARD_RESOURCE: result is List[Resource] discarded in this action +- DISCARD_RESOURCE: result is Resource discarded in this action - MOVE_ROBBER: result is card stolen (Resource|None) - BUY_DEVELOPMENT_CARD: result is card - ...for the rest, result is None since they are deterministic actions diff --git a/catanatron/catanatron/state.py b/catanatron/catanatron/state.py index e93cea79..93f6e52a 100644 --- a/catanatron/catanatron/state.py +++ b/catanatron/catanatron/state.py @@ -76,7 +76,7 @@ class State: current_prompt (ActionPrompt): DEPRECATED. Not needed; use is_initial_build_phase, is_moving_knight, etc... instead. is_discarding (bool): If current player needs to discard. - discard_counts (Dict[Color, int]): Remaining number of cards each player + discard_counts (List[int]): Color-index aligned number of cards each player must discard in the current discard sequence. is_moving_knight (bool): If current player needs to move robber. is_road_building (bool): If current player needs to build free roads per Road @@ -133,7 +133,7 @@ def __init__( self.current_prompt = ActionPrompt.BUILD_INITIAL_SETTLEMENT self.is_initial_build_phase = True self.is_discarding = False - self.discard_counts: Dict[Color, int] = {color: 0 for color in self.colors} + self.discard_counts: List[int] = [0] * len(self.colors) self.is_moving_knight = False self.is_road_building = False self.free_roads_available = 0 diff --git a/tests/models/test_actions.py b/tests/models/test_actions.py index 398f521e..e145f34d 100644 --- a/tests/models/test_actions.py +++ b/tests/models/test_actions.py @@ -60,7 +60,7 @@ def test_monopoly_possible_actions(): def test_discard_possibilities_are_per_resource(): player = SimplePlayer(Color.RED) state = State([player]) - state.discard_counts[player.color] = 2 + state.discard_counts[0] = 2 player_deck_replenish(state, player.color, WHEAT, 2) player_deck_replenish(state, player.color, BRICK, 1) @@ -84,7 +84,7 @@ def test_discard_possibilities_empty_when_player_does_not_need_to_discard(): def test_discard_possibilities_do_not_repeat_same_resource(): player = SimplePlayer(Color.RED) state = State([player]) - state.discard_counts[player.color] = 3 + state.discard_counts[0] = 3 player_deck_replenish(state, player.color, WHEAT, 3) @@ -96,7 +96,7 @@ def test_discard_possibilities_do_not_repeat_same_resource(): def test_discard_possibilities_include_each_resource_in_resource_order(): player = SimplePlayer(Color.RED) state = State([player]) - state.discard_counts[player.color] = len(RESOURCES) + state.discard_counts[0] = len(RESOURCES) for resource in RESOURCES: player_deck_replenish(state, player.color, resource) diff --git a/tests/test_json.py b/tests/test_json.py index a3cb5715..3a0e6aca 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -60,12 +60,6 @@ def test_action_from_json_discard(): assert action.value == WOOD -def test_action_from_json_discard_rejects_list_value(): - data = ["BLUE", "DISCARD_RESOURCE", [WOOD]] - with pytest.raises(ValueError, match="Discard action must have 1 resource"): - action_from_json(data) - - def test_action_from_json_play_year_of_plenty_invalid(): data = ["WHITE", "PLAY_YEAR_OF_PLENTY", [WOOD, BRICK, SHEEP]] with pytest.raises( diff --git a/tests/test_state.py b/tests/test_state.py index 80c79a68..2c169220 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -221,14 +221,14 @@ def test_discard_sequence_tracks_original_hand_size(): state.current_player_index = current_player_index state.current_prompt = ActionPrompt.DISCARD state.is_discarding = True - state.discard_counts[color] = 4 + state.discard_counts[current_player_index] = 4 player_deck_replenish(state, color, BRICK, 1) player_deck_replenish(state, color, WHEAT, 3) apply_action(state, Action(color, ActionType.DISCARD_RESOURCE, BRICK)) assert state.current_color() == color assert state.is_discarding - assert state.discard_counts[color] == 3 + assert state.discard_counts[current_player_index] == 3 apply_action(state, Action(color, ActionType.DISCARD_RESOURCE, WHEAT)) apply_action(state, Action(color, ActionType.DISCARD_RESOURCE, WHEAT)) @@ -236,3 +236,35 @@ def test_discard_sequence_tracks_original_hand_size(): assert not state.is_discarding assert state.is_moving_knight assert state.current_player_index == turn_player_index + + +def test_discard_sequence_advances_to_next_discarder(): + players = [ + SimplePlayer(Color.RED), + SimplePlayer(Color.BLUE), + SimplePlayer(Color.WHITE), + ] + state = State(players) + current_color = players[0].color + next_color = players[2].color + current_player_index = state.colors.index(current_color) + next_player_index = state.colors.index(next_color) + + state.current_turn_index = 1 + state.current_player_index = current_player_index + state.current_prompt = ActionPrompt.DISCARD + state.is_discarding = True + state.discard_counts[current_player_index] = 1 + state.discard_counts[next_player_index] = 2 + + player_deck_replenish(state, current_color, BRICK, 1) + + apply_action( + state, + Action(current_color, ActionType.DISCARD_RESOURCE, BRICK), + ) + + assert state.current_player_index == next_player_index + assert state.current_color() == next_color + assert state.is_discarding + assert state.current_prompt == ActionPrompt.DISCARD From 4a789c54c7472f43c554f37e4bc413840d1f9324 Mon Sep 17 00:00:00 2001 From: Bryan Collazo Date: Mon, 30 Mar 2026 20:25:06 -0400 Subject: [PATCH 3/6] Update UI to use ActionRecords only --- ui/src/components/LeftDrawer.tsx | 20 ++++++++----- ui/src/components/Snackbar.tsx | 40 +++++++++++-------------- ui/src/pages/ActionsToolbar.tsx | 50 +++++++++++++++----------------- ui/src/utils/api.types.ts | 19 +++++------- ui/src/utils/promptUtils.ts | 20 +++---------- 5 files changed, 65 insertions(+), 84 deletions(-) diff --git a/ui/src/components/LeftDrawer.tsx b/ui/src/components/LeftDrawer.tsx index 1602c24f..758326ec 100644 --- a/ui/src/components/LeftDrawer.tsx +++ b/ui/src/components/LeftDrawer.tsx @@ -34,11 +34,17 @@ function DrawerContent({ gameState }: { gameState: GameState }) { <> {playerSections}
- {gameState.action_records?.slice().reverse().map((actionRecord, i) => ( -
- {humanizeActionRecord(gameState, actionRecord)} -
- ))} + {gameState.action_records + .slice() + .reverse() + .map((actionRecord, i) => ( +
+ {humanizeActionRecord(gameState, actionRecord)} +
+ ))}
); @@ -56,7 +62,7 @@ export default function LeftDrawer() { dispatch({ type: ACTIONS.SET_LEFT_DRAWER_OPENED, data: true }); }, - [dispatch] + [dispatch], ); const closeLeftDrawer = useCallback( (event: InteractionEvent) => { @@ -66,7 +72,7 @@ export default function LeftDrawer() { dispatch({ type: ACTIONS.SET_LEFT_DRAWER_OPENED, data: false }); }, - [dispatch] + [dispatch], ); return ( diff --git a/ui/src/components/Snackbar.tsx b/ui/src/components/Snackbar.tsx index cf454f46..155e55f5 100644 --- a/ui/src/components/Snackbar.tsx +++ b/ui/src/components/Snackbar.tsx @@ -1,46 +1,40 @@ import { IconButton } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import type { GameState } from "../utils/api.types"; -import { latestActionText } from "../utils/promptUtils"; +import { humanizeActionRecord } from "../utils/promptUtils"; // No types exported from notistack; type SnackbarKey = string | number; export const snackbarActions = - (closeSnackbar: (key?: SnackbarKey) => void) => (key: string) => - ( - <> - closeSnackbar(key)} - > - - - - ); + (closeSnackbar: (key?: SnackbarKey) => void) => (key: string) => ( + <> + closeSnackbar(key)} + > + + + + ); export function dispatchSnackbar( enqueueSnackbar: ( message: string, - options: { action: (key: string) => React.ReactNode; onClick: () => void } + options: { action: (key: string) => React.ReactNode; onClick: () => void }, ) => SnackbarKey, closeSnackbar: (key?: string | number) => void, - gameState: GameState + gameState: GameState, ) { - const message = latestActionText(gameState); - if (!message) { - return; - } - enqueueSnackbar( - message, + humanizeActionRecord(gameState, gameState.action_records.slice(-1)[0]), { action: snackbarActions(closeSnackbar), onClick: () => { closeSnackbar(); }, - } + }, ); } diff --git a/ui/src/pages/ActionsToolbar.tsx b/ui/src/pages/ActionsToolbar.tsx index fc58b8a9..672445f9 100644 --- a/ui/src/pages/ActionsToolbar.tsx +++ b/ui/src/pages/ActionsToolbar.tsx @@ -60,7 +60,7 @@ function PlayButtons() { dispatch({ type: ACTIONS.SET_GAME_STATE, data: gameState }); dispatchSnackbar(enqueueSnackbar, closeSnackbar, gameState); }), - [enqueueSnackbar, closeSnackbar] + [enqueueSnackbar, closeSnackbar], ); const { @@ -83,21 +83,19 @@ function PlayButtons() { const playableDevCardTypes = new Set( gameState.current_playable_actions .filter((action) => action[1].startsWith("PLAY")) - .map((action) => action[1]) + .map((action) => action[1]), ); const humanColor = getHumanColor(gameState); const discardActionType = gameState.current_playable_actions.find( - (action) => action[1] === "DISCARD" || action[1] === "DISCARD_RESOURCE" + (action) => action[1] === "DISCARD_RESOURCE", )?.[1] ?? "DISCARD_RESOURCE"; const setIsPlayingMonopoly = useCallback(() => { dispatch({ type: ACTIONS.SET_IS_PLAYING_MONOPOLY }); }, [dispatch]); const getValidDiscardOptions = useCallback(() => { const discardOptions = gameState.current_playable_actions - .filter( - (action) => action[1] === "DISCARD" || action[1] === "DISCARD_RESOURCE" - ) + .filter((action) => action[1] === "DISCARD_RESOURCE") .map((action) => action[2] as ResourceCard); if (discardOptions.length > 0) { return discardOptions; @@ -105,7 +103,7 @@ function PlayButtons() { // Fallback to the current hand if the discard actions are missing from the payload. return RESOURCE_ORDER.filter( - (resource) => gameState.player_state[`${key}_${resource}_IN_HAND`] > 0 + (resource) => gameState.player_state[`${key}_${resource}_IN_HAND`] > 0, ); }, [gameState.current_playable_actions, gameState.player_state, key]); const getValidYearOfPlentyOptions = useCallback(() => { @@ -153,7 +151,7 @@ function PlayButtons() { isPlayingYearOfPlenty, isDiscard, discardActionType, - ] + ], ); const handleOpenResourceSelector = useCallback(() => { setResourceSelectorOpen(true); @@ -203,9 +201,9 @@ function PlayButtons() { : gameState.current_playable_actions .filter( (action) => - action[1].startsWith("BUY") || action[1].startsWith("BUILD") + action[1].startsWith("BUY") || action[1].startsWith("BUILD"), ) - .map((a) => a[1]) + .map((a) => a[1]), ); const buyDevCard = useCallback(async () => { const action: GameAction = [humanColor, "BUY_DEVELOPMENT_CARD", null]; @@ -246,7 +244,7 @@ function PlayButtons() { ]; const tradeActions = gameState.current_playable_actions.filter( - (action) => action[1] === "MARITIME_TRADE" + (action) => action[1] === "MARITIME_TRADE", ); const tradeItems = React.useMemo(() => { const items = tradeActions.map((action) => { @@ -301,23 +299,23 @@ function PlayButtons() { isDiscard ? handleOpenResourceSelector : isMoveRobber - ? setIsMovingRobber - : isPlayingYearOfPlenty || isPlayingMonopoly - ? handleOpenResourceSelector - : isRoll - ? rollAction - : endTurnAction + ? setIsMovingRobber + : isPlayingYearOfPlenty || isPlayingMonopoly + ? handleOpenResourceSelector + : isRoll + ? rollAction + : endTurnAction } > {isDiscard ? "DISCARD" : isMoveRobber - ? "ROB" - : isPlayingYearOfPlenty || isPlayingMonopoly - ? "SELECT" - : isRoll - ? "ROLL" - : "END"} + ? "ROB" + : isPlayingYearOfPlenty || isPlayingMonopoly + ? "SELECT" + : isRoll + ? "ROLL" + : "END"} @@ -518,7 +516,7 @@ function OptionsButton({ key={item.label} onClick={ handleClose( - item.onClick + item.onClick, ) as unknown as React.MouseEventHandler } disabled={item.disabled} diff --git a/ui/src/utils/api.types.ts b/ui/src/utils/api.types.ts index 7512122f..de677b1f 100644 --- a/ui/src/utils/api.types.ts +++ b/ui/src/utils/api.types.ts @@ -11,7 +11,7 @@ export type TileCoordinate = [number, number, number]; export type GameActionRecord = // These are the special cases | [RollGameAction, [number, number]] - | [DiscardGameAction, ResourceCard[]] + | [DiscardGameAction, ResourceCard] | [MoveRobberAction, ResourceCard | null] | [BuyDevelopmentCardAction, DevelopmentCard] // These are deterministic and carry no extra info @@ -25,12 +25,8 @@ export type GameActionRecord = | [MaritimeTradeAction, null] | [EndTurnAction, null]; -export type RollGameAction = [Color, "ROLL", [number, number] | null]; -export type DiscardGameAction = [ - Color, - "DISCARD" | "DISCARD_RESOURCE", - ResourceCard | null -]; +export type RollGameAction = [Color, "ROLL", null]; +export type DiscardGameAction = [Color, "DISCARD_RESOURCE", ResourceCard]; export type BuyDevelopmentCardAction = [Color, "BUY_DEVELOPMENT_CARD", null]; export type BuildSettlementAction = [Color, "BUILD_SETTLEMENT", number]; export type BuildCityAction = [Color, "BUILD_CITY", number]; @@ -41,17 +37,17 @@ export type PlayMonopolyAction = [Color, "PLAY_MONOPOLY", ResourceCard]; export type PlayYearOfPlentyAction = [ Color, "PLAY_YEAR_OF_PLENTY", - [ResourceCard] | [ResourceCard, ResourceCard] + [ResourceCard] | [ResourceCard, ResourceCard], ]; export type MoveRobberAction = [ Color, "MOVE_ROBBER", - [TileCoordinate, string?, string?] + [TileCoordinate, string?], ]; export type MaritimeTradeAction = [ Color, "MARITIME_TRADE", - (ResourceCard | null)[] + (ResourceCard | null)[], ]; export type EndTurnAction = [Color, "END_TURN", null]; @@ -110,8 +106,7 @@ export type GameState = { winning_color?: Color; current_prompt: string; player_state: Record; - action_records?: GameActionRecord[]; - actions?: GameAction[]; + action_records: GameActionRecord[]; robber_coordinate: TileCoordinate; nodes: Array<{ id: number; diff --git a/ui/src/utils/promptUtils.ts b/ui/src/utils/promptUtils.ts index 16cb0daf..52f2b4a3 100644 --- a/ui/src/utils/promptUtils.ts +++ b/ui/src/utils/promptUtils.ts @@ -13,7 +13,7 @@ import type { GameState } from "./api.types"; export function humanizeActionRecord( gameState: GameState, - actionRecord: GameActionRecord + actionRecord: GameActionRecord, ) { const botColors = gameState.bot_colors; const action = actionRecord[0]; @@ -23,11 +23,8 @@ export function humanizeActionRecord( const action = actionRecord[1] as [number, number]; return `${player} ROLLED A ${action[0] + action[1]}`; } - case "DISCARD": case "DISCARD_RESOURCE": - return `${player} DISCARDED ${ - (actionRecord[1] as ResourceCard[])[0] - }`; + return `${player} DISCARDED ${actionRecord[1] as ResourceCard}`; case "BUY_DEVELOPMENT_CARD": return `${player} BOUGHT DEVELOPMENT CARD`; case "BUILD_SETTLEMENT": @@ -47,7 +44,7 @@ export function humanizeActionRecord( const b = gameState.adjacent_tiles[edge[1]].map((t) => t.id); const intersection = a.filter((t) => b.includes(t)); const tiles = intersection.map( - (tileId) => findTileById(gameState, tileId).tile + (tileId) => findTileById(gameState, tileId).tile, ); const edgeString = tiles.map(getShortTileString).join("-"); return `${player} BUILT ROAD ON ${edgeString}`; @@ -96,15 +93,6 @@ export function humanizeTradeAction(action: MaritimeTradeAction): string { return `${out.length} ${out[0]} => ${action[2][4]}`; } -export function latestActionText(gameState: GameState) { - const latestActionRecord = gameState.action_records?.slice(-1)[0]; - if (latestActionRecord) { - return humanizeActionRecord(gameState, latestActionRecord); - } - - return ""; -} - export function findTileByCoordinate(gameState: GameState, coordinate: any) { for (const tile of Object.values(gameState.tiles)) { if (JSON.stringify(tile.coordinate) === JSON.stringify(coordinate)) { @@ -112,7 +100,7 @@ export function findTileByCoordinate(gameState: GameState, coordinate: any) { } } throw new Error( - `Tile not found for coordinate: ${JSON.stringify(coordinate)}` + `Tile not found for coordinate: ${JSON.stringify(coordinate)}`, ); } From fafd04f4f31e09326d8dd226b927f9ccd90f8909 Mon Sep 17 00:00:00 2001 From: Bryan Collazo Date: Mon, 30 Mar 2026 20:29:42 -0400 Subject: [PATCH 4/6] UI Nits --- ui/src/components/ResourceSelector.scss | 27 +++++++++++++++++-------- ui/src/components/ResourceSelector.tsx | 15 +++++++++----- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/ui/src/components/ResourceSelector.scss b/ui/src/components/ResourceSelector.scss index a5635b18..aaf7c856 100644 --- a/ui/src/components/ResourceSelector.scss +++ b/ui/src/components/ResourceSelector.scss @@ -23,10 +23,11 @@ } .resource-grid { + padding-top: 20px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; - + @media (max-width: variables.$sm-breakpoint) { grid-template-columns: 1fr; } @@ -48,11 +49,21 @@ background-color: #222; } - .wood { color: #009b00; } - .brick { color: #ff0000; } - .wheat { color: #ba9202; } - .sheep { color: #3eda58; } - .ore { color: #b0b0b0; } + .wood { + color: #009b00; + } + .brick { + color: #ff0000; + } + .wheat { + color: #ba9202; + } + .sheep { + color: #3eda58; + } + .ore { + color: #b0b0b0; + } .resource-name { font-weight: bold; @@ -73,9 +84,9 @@ .cancel-button { color: #0099ff; font-weight: bold; - + &:hover { background-color: rgba(0, 153, 255, 0.1); } } -} \ No newline at end of file +} diff --git a/ui/src/components/ResourceSelector.tsx b/ui/src/components/ResourceSelector.tsx index ae3a06b8..40edb0c1 100644 --- a/ui/src/components/ResourceSelector.tsx +++ b/ui/src/components/ResourceSelector.tsx @@ -35,7 +35,7 @@ const ResourceSelector = ({ "ORE", ]; const isSingleResourceOption = ( - option: SelectorOption + option: SelectorOption, ): option is ResourceCard => !Array.isArray(option); const sortedOptions = React.useMemo(() => { @@ -49,10 +49,10 @@ const ResourceSelector = ({ } const yearOfPlentyOptions = options.filter( - (option): option is ResourceCard[] => Array.isArray(option) + (option): option is ResourceCard[] => Array.isArray(option), ); const hasDoubleOptions = yearOfPlentyOptions.some( - (option) => option.length === 2 + (option) => option.length === 2, ); const filteredOptions = hasDoubleOptions ? yearOfPlentyOptions.filter((option) => option.length === 2) @@ -114,8 +114,13 @@ const ResourceSelector = ({ {mode === "discard" ? "Select Resource to Discard" : mode === "monopoly" - ? "Select Resource to Monopolize" - : "Select Resources for Year of Plenty"} + ? "Select Resource to Monopolize" + : "Select Resources for Year of Plenty"} + {mode === "discard" && ( + + (Discards happen one card at a time) + + )}
From 22ae9c47a4889de81603482763ffeab3839f51d7 Mon Sep 17 00:00:00 2001 From: Bryan Collazo Date: Mon, 30 Mar 2026 20:36:56 -0400 Subject: [PATCH 5/6] Improve apply_roll state.discard_counts setting --- catanatron/catanatron/apply_action.py | 29 +++++++++++++-------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/catanatron/catanatron/apply_action.py b/catanatron/catanatron/apply_action.py index 46c73951..72900578 100644 --- a/catanatron/catanatron/apply_action.py +++ b/catanatron/catanatron/apply_action.py @@ -266,22 +266,21 @@ def apply_roll(state: State, action: Action, action_record=None): action = Action(action.color, action.action_type, dices) if number == 7: - state.discard_counts = [ - ( - player_num_resource_cards(state, color) // 2 - if player_num_resource_cards(state, color) > state.discard_limit - else 0 - ) - for color in state.colors - ] - should_enter_discarding_sequence = any( - count > 0 for count in state.discard_counts - ) + discard_counts = [] + first_discarding_player_index = None - if should_enter_discarding_sequence: - state.current_player_index = next( - i for i, count in enumerate(state.discard_counts) if count > 0 - ) + for i, color in enumerate(state.colors): + num_cards = player_num_resource_cards(state, color) + discard_count = num_cards // 2 if num_cards > state.discard_limit else 0 + discard_counts.append(discard_count) + + if discard_count > 0 and first_discarding_player_index is None: + first_discarding_player_index = i + + state.discard_counts = discard_counts + + if first_discarding_player_index is not None: + state.current_player_index = first_discarding_player_index state.current_prompt = ActionPrompt.DISCARD state.is_discarding = True else: From f4c899f3528d9e8998c6b32fd158561e23170e49 Mon Sep 17 00:00:00 2001 From: Bryan Collazo Date: Mon, 30 Mar 2026 21:47:01 -0400 Subject: [PATCH 6/6] Make FE Modal MultiDiscard --- catanatron/catanatron/json.py | 3 + ui/src/components/DiscardPlannerDialog.scss | 117 ++++++++++++++ ui/src/components/DiscardPlannerDialog.tsx | 160 ++++++++++++++++++++ ui/src/components/ResourceSelector.scss | 1 - ui/src/components/ResourceSelector.tsx | 66 ++++---- ui/src/hooks/useDiscardBatchSubmission.ts | 48 ++++++ ui/src/pages/ActionsToolbar.tsx | 96 +++++++----- ui/src/pages/ZoomableBoard.tsx | 8 +- ui/src/utils/api.types.ts | 1 + ui/src/utils/promptUtils.test.ts | 2 +- 10 files changed, 422 insertions(+), 80 deletions(-) create mode 100644 ui/src/components/DiscardPlannerDialog.scss create mode 100644 ui/src/components/DiscardPlannerDialog.tsx create mode 100644 ui/src/hooks/useDiscardBatchSubmission.ts diff --git a/catanatron/catanatron/json.py b/catanatron/catanatron/json.py index 9d9756d4..41967a4c 100644 --- a/catanatron/catanatron/json.py +++ b/catanatron/catanatron/json.py @@ -97,6 +97,9 @@ def default(self, obj): "robber_coordinate": obj.state.board.robber_coordinate, "current_color": obj.state.current_color(), "current_prompt": obj.state.current_prompt, + "current_discard_count": obj.state.discard_counts[ + obj.state.current_player_index + ], "current_playable_actions": obj.playable_actions, "longest_roads_by_player": longest_roads_by_player(obj.state), "winning_color": obj.winning_color(), diff --git a/ui/src/components/DiscardPlannerDialog.scss b/ui/src/components/DiscardPlannerDialog.scss new file mode 100644 index 00000000..77b010a2 --- /dev/null +++ b/ui/src/components/DiscardPlannerDialog.scss @@ -0,0 +1,117 @@ +@use "../variables.scss"; + +.discard-planner { + .MuiPaper-root { + background-color: #1a1a1a; + color: #e0e0e0; + border-radius: 8px; + border: 1px solid #333; + box-shadow: 0 0 10px rgba(0, 150, 255, 0.05); + } + + .MuiDialogTitle-root { + text-align: center; + background-color: #222; + padding: 12px 20px; + font-size: 1.1rem; + font-weight: bold; + border-bottom: 1px solid #333; + } + + .MuiDialogContent-root { + padding: 20px; + } + + .discard-note { + margin-top: 4px; + } + + .discard-summary { + padding-top: 16px; + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; + } + + .selected-discard-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .selected-discard-chip { + text-transform: none; + } + + .resource-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + + @media (max-width: variables.$sm-breakpoint) { + grid-template-columns: 1fr; + } + } + + .resource-button { + min-height: 54px; + font-size: 0.9rem; + text-transform: none; + background-color: #2a2a2a; + border-radius: 4px; + transition: all 0.3s ease; + border: 1px solid #444; + overflow: hidden; + position: relative; + + &:hover, + &:focus { + background-color: #222; + } + + .wood { + color: #009b00; + } + .brick { + color: #ff0000; + } + .wheat { + color: #ba9202; + } + .sheep { + color: #3eda58; + } + .ore { + color: #b0b0b0; + } + + .resource-name { + font-weight: bold; + } + + .resource-meta { + color: #c7c7c7; + font-size: 0.75rem; + } + } + + .MuiDialogActions-root { + background-color: #222; + padding: 12px 20px; + border-top: 1px solid #333; + } + + .cancel-button { + color: #0099ff; + font-weight: bold; + + &:hover { + background-color: rgba(0, 153, 255, 0.1); + } + } + + .clear-button { + color: #c7c7c7; + } +} diff --git a/ui/src/components/DiscardPlannerDialog.tsx b/ui/src/components/DiscardPlannerDialog.tsx new file mode 100644 index 00000000..c273f519 --- /dev/null +++ b/ui/src/components/DiscardPlannerDialog.tsx @@ -0,0 +1,160 @@ +import React from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from "@mui/material"; +import type { ResourceCard } from "../utils/api.types"; +import "./DiscardPlannerDialog.scss"; + +type DiscardResourceCounts = Partial>; + +type DiscardPlannerDialogProps = { + open: boolean; + onClose: () => void; + onConfirm: (resources: ResourceCard[]) => void; + remainingDiscardCount: number; + discardResourceCounts: DiscardResourceCounts; + submitting?: boolean; +}; + +const RESOURCE_ORDER: ResourceCard[] = [ + "WOOD", + "BRICK", + "SHEEP", + "WHEAT", + "ORE", +]; + +export default function DiscardPlannerDialog({ + open, + onClose, + onConfirm, + remainingDiscardCount, + discardResourceCounts, + submitting = false, +}: DiscardPlannerDialogProps) { + const [discardSelections, setDiscardSelections] = React.useState( + [], + ); + + React.useEffect(() => { + if (!open) { + setDiscardSelections([]); + } + }, [open]); + + const selectedCount = discardSelections.length; + const selectedResourceCount = (resource: ResourceCard) => + discardSelections.filter((selection) => selection === resource).length; + const canSelectResource = (resource: ResourceCard) => { + const availableCount = discardResourceCounts[resource] ?? 0; + return ( + selectedCount < remainingDiscardCount && + selectedResourceCount(resource) < availableCount + ); + }; + const addDiscardSelection = (resource: ResourceCard) => { + if (!canSelectResource(resource) || submitting) { + return; + } + setDiscardSelections((current) => [...current, resource]); + }; + const removeDiscardSelection = (resource: ResourceCard) => { + setDiscardSelections((current) => { + const index = current.lastIndexOf(resource); + if (index === -1) { + return current; + } + return [...current.slice(0, index), ...current.slice(index + 1)]; + }); + }; + const groupedDiscardSelections = RESOURCE_ORDER.map((resource) => ({ + resource, + count: selectedResourceCount(resource), + })).filter(({ count }) => count > 0); + + return ( + + + Select Your Discards + + {selectedCount === 0 + ? `Choose ${remainingDiscardCount} card${remainingDiscardCount === 1 ? "" : "s"}.` + : `${selectedCount} of ${remainingDiscardCount} selected.`} + + + +
+ {groupedDiscardSelections.length > 0 && ( +
+ {groupedDiscardSelections.map(({ resource, count }) => ( + + ))} +
+ )} +
+
+ {RESOURCE_ORDER.filter((resource) => discardResourceCounts[resource] != null).map( + (resource) => ( + + ), + )} +
+
+ + + + + +
+ ); +} diff --git a/ui/src/components/ResourceSelector.scss b/ui/src/components/ResourceSelector.scss index aaf7c856..fefaf90e 100644 --- a/ui/src/components/ResourceSelector.scss +++ b/ui/src/components/ResourceSelector.scss @@ -23,7 +23,6 @@ } .resource-grid { - padding-top: 20px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; diff --git a/ui/src/components/ResourceSelector.tsx b/ui/src/components/ResourceSelector.tsx index 40edb0c1..9811d89c 100644 --- a/ui/src/components/ResourceSelector.tsx +++ b/ui/src/components/ResourceSelector.tsx @@ -17,35 +17,31 @@ type ResourceSelectorProps = { onClose: () => void; onSelect: (option: SelectorOption) => void; options: SelectorOption[]; - mode: "discard" | "monopoly" | "yearOfPlenty"; + mode: "monopoly" | "yearOfPlenty"; }; -const ResourceSelector = ({ +const RESOURCE_ORDER: ResourceCard[] = [ + "WOOD", + "BRICK", + "SHEEP", + "WHEAT", + "ORE", +]; + +export default function ResourceSelector({ open, onClose, options, onSelect, mode, -}: ResourceSelectorProps) => { - const resourceOrder: ResourceCard[] = [ - "WOOD", - "BRICK", - "SHEEP", - "WHEAT", - "ORE", - ]; +}: ResourceSelectorProps) { const isSingleResourceOption = ( option: SelectorOption, ): option is ResourceCard => !Array.isArray(option); const sortedOptions = React.useMemo(() => { if (mode === "monopoly") { - return resourceOrder; - } - if (mode === "discard") { - return options - .filter(isSingleResourceOption) - .sort((a, b) => resourceOrder.indexOf(a) - resourceOrder.indexOf(b)); + return RESOURCE_ORDER; } const yearOfPlentyOptions = options.filter( @@ -63,12 +59,12 @@ const ResourceSelector = ({ const bFirstResource = b[0]; if (aFirstResource !== bFirstResource) { return ( - resourceOrder.indexOf(aFirstResource) - - resourceOrder.indexOf(bFirstResource) + RESOURCE_ORDER.indexOf(aFirstResource) - + RESOURCE_ORDER.indexOf(bFirstResource) ); } if (a.length === 2 && b.length === 2) { - return resourceOrder.indexOf(a[1]) - resourceOrder.indexOf(b[1]); + return RESOURCE_ORDER.indexOf(a[1]) - RESOURCE_ORDER.indexOf(b[1]); } return a.length === 1 ? 1 : -1; }); @@ -91,15 +87,14 @@ const ResourceSelector = ({ x1 ); - } else { - return ( - <> - {getResourceSpan(option[0])} - + - {getResourceSpan(option[1])} - - ); } + return ( + <> + {getResourceSpan(option[0])} + + + {getResourceSpan(option[1])} + + ); }; return ( @@ -111,16 +106,9 @@ const ResourceSelector = ({ fullWidth > - {mode === "discard" - ? "Select Resource to Discard" - : mode === "monopoly" - ? "Select Resource to Monopolize" - : "Select Resources for Year of Plenty"} - {mode === "discard" && ( - - (Discards happen one card at a time) - - )} + {mode === "monopoly" + ? "Select Resource to Monopolize" + : "Select Resources for Year of Plenty"}
@@ -145,6 +133,4 @@ const ResourceSelector = ({ ); -}; - -export default ResourceSelector; +} diff --git a/ui/src/hooks/useDiscardBatchSubmission.ts b/ui/src/hooks/useDiscardBatchSubmission.ts new file mode 100644 index 00000000..dd0b4aa4 --- /dev/null +++ b/ui/src/hooks/useDiscardBatchSubmission.ts @@ -0,0 +1,48 @@ +import { useCallback, useState } from "react"; +import type { + DiscardGameAction, + GameAction, + GameState, + ResourceCard, +} from "../utils/api.types"; +import { postAction } from "../utils/apiClient"; + +type SubmitDiscardBatchParams = { + discardActionType: DiscardGameAction[1]; + gameId: string; + humanColor: GameAction[0]; + resources: ResourceCard[]; +}; + +export function useDiscardBatchSubmission() { + const [isSubmitting, setIsSubmitting] = useState(false); + + const submitDiscardBatch = useCallback( + async ({ + discardActionType, + gameId, + humanColor, + resources, + }: SubmitDiscardBatchParams): Promise => { + setIsSubmitting(true); + try { + let nextGameState: GameState | null = null; + for (const resource of resources) { + const action: GameAction = [humanColor, discardActionType, resource]; + nextGameState = await postAction(gameId, action); + } + + if (nextGameState === null) { + throw new Error("Discard batch submitted with no resources selected."); + } + + return nextGameState; + } finally { + setIsSubmitting(false); + } + }, + [], + ); + + return { isSubmitting, submitDiscardBatch }; +} diff --git a/ui/src/pages/ActionsToolbar.tsx b/ui/src/pages/ActionsToolbar.tsx index 672445f9..d759e99f 100644 --- a/ui/src/pages/ActionsToolbar.tsx +++ b/ui/src/pages/ActionsToolbar.tsx @@ -25,12 +25,14 @@ import Hidden from "../components/Hidden"; import Prompt from "../components/Prompt"; import ResourceCards from "../components/ResourceCards"; import ResourceSelector from "../components/ResourceSelector"; +import DiscardPlannerDialog from "../components/DiscardPlannerDialog"; import { store } from "../store"; import ACTIONS from "../actions"; import type { GameAction, ResourceCard } from "../utils/api.types"; // Add GameState to the import, adjust path if needed import { getHumanColor, playerKey } from "../utils/stateUtils"; import { postAction } from "../utils/apiClient"; import { humanizeTradeAction } from "../utils/promptUtils"; +import { useDiscardBatchSubmission } from "../hooks/useDiscardBatchSubmission"; import "./ActionsToolbar.scss"; import { useSnackbar } from "notistack"; @@ -53,6 +55,8 @@ function PlayButtons() { const { state, dispatch } = useContext(store); const { enqueueSnackbar, closeSnackbar } = useSnackbar(); const [resourceSelectorOpen, setResourceSelectorOpen] = useState(false); + const [discardPlannerOpen, setDiscardPlannerOpen] = useState(false); + const { isSubmitting, submitDiscardBatch } = useDiscardBatchSubmission(); const carryOutAction = useCallback( memoize((action?: GameAction) => async () => { @@ -93,19 +97,18 @@ function PlayButtons() { const setIsPlayingMonopoly = useCallback(() => { dispatch({ type: ACTIONS.SET_IS_PLAYING_MONOPOLY }); }, [dispatch]); - const getValidDiscardOptions = useCallback(() => { - const discardOptions = gameState.current_playable_actions - .filter((action) => action[1] === "DISCARD_RESOURCE") - .map((action) => action[2] as ResourceCard); - if (discardOptions.length > 0) { - return discardOptions; - } - - // Fallback to the current hand if the discard actions are missing from the payload. - return RESOURCE_ORDER.filter( - (resource) => gameState.player_state[`${key}_${resource}_IN_HAND`] > 0, + const getDiscardResourceCounts = useCallback(() => { + return RESOURCE_ORDER.reduce( + (counts, resource) => { + const inHand = gameState.player_state[`${key}_${resource}_IN_HAND`]; + if (inHand > 0) { + counts[resource] = inHand; + } + return counts; + }, + {} as Partial>, ); - }, [gameState.current_playable_actions, gameState.player_state, key]); + }, [gameState.player_state, key]); const getValidYearOfPlentyOptions = useCallback(() => { return gameState.current_playable_actions .filter((action) => action[1] === "PLAY_YEAR_OF_PLENTY") @@ -114,6 +117,7 @@ function PlayButtons() { const handleResourceSelection = useCallback( async (selectedResources: ResourceCard | ResourceCard[]) => { setResourceSelectorOpen(false); + let nextGameState; let action: GameAction; if (isPlayingMonopoly) { action = [ @@ -121,25 +125,20 @@ function PlayButtons() { "PLAY_MONOPOLY", selectedResources as ResourceCard, ]; - } else if (isDiscard) { - action = [ - humanColor, - discardActionType, - selectedResources as ResourceCard, - ]; + nextGameState = await postAction(gameId, action); } else if (isPlayingYearOfPlenty) { action = [ humanColor, "PLAY_YEAR_OF_PLENTY", selectedResources as [ResourceCard] | [ResourceCard, ResourceCard], ]; + nextGameState = await postAction(gameId, action); } else { console.error("Invalid resource selector mode"); return; } - const gameState = await postAction(gameId, action); - dispatch({ type: ACTIONS.SET_GAME_STATE, data: gameState }); - dispatchSnackbar(enqueueSnackbar, closeSnackbar, gameState); + dispatch({ type: ACTIONS.SET_GAME_STATE, data: nextGameState }); + dispatchSnackbar(enqueueSnackbar, closeSnackbar, nextGameState); }, [ gameId, @@ -149,13 +148,36 @@ function PlayButtons() { closeSnackbar, isPlayingMonopoly, isPlayingYearOfPlenty, - isDiscard, - discardActionType, ], ); const handleOpenResourceSelector = useCallback(() => { setResourceSelectorOpen(true); }, []); + const handleOpenDiscardPlanner = useCallback(() => { + setDiscardPlannerOpen(true); + }, []); + const handleDiscardSelection = useCallback( + async (resources: ResourceCard[]) => { + setDiscardPlannerOpen(false); + const nextGameState = await submitDiscardBatch({ + discardActionType, + gameId, + humanColor, + resources, + }); + dispatch({ type: ACTIONS.SET_GAME_STATE, data: nextGameState }); + dispatchSnackbar(enqueueSnackbar, closeSnackbar, nextGameState); + }, + [ + discardActionType, + gameId, + humanColor, + submitDiscardBatch, + dispatch, + enqueueSnackbar, + closeSnackbar, + ], + ); const setIsPlayingYearOfPlenty = useCallback(() => { dispatch({ type: ACTIONS.SET_IS_PLAYING_YEAR_OF_PLENTY }); }, [dispatch]); @@ -291,13 +313,17 @@ function PlayButtons() { Trade