diff --git a/README.md b/README.md
index aefbd68..dfd665f 100644
--- a/README.md
+++ b/README.md
@@ -86,7 +86,10 @@ When a player reveals all 6 cards, others get one final turn. Lowest score wins.
- `blackjack` - Score of exactly 21 becomes 0
### Gameplay Options
-- `flip_on_discard` - Must flip a card when discarding from deck
+- `flip_mode` - What happens when discarding from deck:
+ - `never` - Standard (no flip)
+ - `always` - Speed Golf (must flip after discard)
+ - `endgame` - Suspense (optional flip when any player has ≤1 face-down card)
- `use_jokers` - Add Jokers to deck
- `eagle_eye` - Paired Jokers score -8 instead of canceling
diff --git a/client/app.js b/client/app.js
index 7a840a2..475921c 100644
--- a/client/app.js
+++ b/client/app.js
@@ -138,8 +138,13 @@ class GolfGame {
this.deckRecommendation = document.getElementById('deck-recommendation');
this.numRoundsSelect = document.getElementById('num-rounds');
this.initialFlipsSelect = document.getElementById('initial-flips');
- this.flipOnDiscardCheckbox = document.getElementById('flip-on-discard');
+ this.flipModeSelect = document.getElementById('flip-mode');
this.knockPenaltyCheckbox = document.getElementById('knock-penalty');
+
+ // Rules screen elements
+ this.rulesScreen = document.getElementById('rules-screen');
+ this.rulesBtn = document.getElementById('rules-btn');
+ this.rulesBackBtn = document.getElementById('rules-back-btn');
// House Rules - Point Modifiers
this.superKingsCheckbox = document.getElementById('super-kings');
this.tenPennyCheckbox = document.getElementById('ten-penny');
@@ -170,6 +175,7 @@ class GolfGame {
this.discard = document.getElementById('discard');
this.discardContent = document.getElementById('discard-content');
this.discardBtn = document.getElementById('discard-btn');
+ this.skipFlipBtn = document.getElementById('skip-flip-btn');
this.playerCards = document.getElementById('player-cards');
this.playerArea = this.playerCards.closest('.player-area');
this.swapAnimation = document.getElementById('swap-animation');
@@ -193,6 +199,7 @@ class GolfGame {
this.deck.addEventListener('click', () => { this.playSound('card'); this.drawFromDeck(); });
this.discard.addEventListener('click', () => { this.playSound('card'); this.drawFromDiscard(); });
this.discardBtn.addEventListener('click', () => { this.playSound('card'); this.discardDrawn(); });
+ this.skipFlipBtn.addEventListener('click', () => { this.playSound('click'); this.skipFlip(); });
this.nextRoundBtn.addEventListener('click', () => { this.playSound('click'); this.nextRound(); });
this.newGameBtn.addEventListener('click', () => { this.playSound('click'); this.newGame(); });
this.addCpuBtn.addEventListener('click', () => { this.playSound('click'); this.showCpuSelect(); });
@@ -236,6 +243,30 @@ class GolfGame {
}
});
}
+
+ // Rules screen navigation
+ if (this.rulesBtn) {
+ this.rulesBtn.addEventListener('click', () => {
+ this.playSound('click');
+ this.showRulesScreen();
+ });
+ }
+ if (this.rulesBackBtn) {
+ this.rulesBackBtn.addEventListener('click', () => {
+ this.playSound('click');
+ this.showLobby();
+ });
+ }
+ }
+
+ showRulesScreen(scrollToSection = null) {
+ this.showScreen(this.rulesScreen);
+ if (scrollToSection) {
+ const section = document.getElementById(scrollToSection);
+ if (section) {
+ section.scrollIntoView({ behavior: 'smooth' });
+ }
+ }
}
connect() {
@@ -367,7 +398,12 @@ class GolfGame {
case 'can_flip':
this.waitingForFlip = true;
- this.showToast('Flip a face-down card', '', 3000);
+ this.flipIsOptional = data.optional || false;
+ if (this.flipIsOptional) {
+ this.showToast('Flip a card or skip', '', 3000);
+ } else {
+ this.showToast('Flip a face-down card', '', 3000);
+ }
this.renderGame();
break;
@@ -450,7 +486,7 @@ class GolfGame {
const initial_flips = parseInt(this.initialFlipsSelect.value);
// Standard options
- const flip_on_discard = this.flipOnDiscardCheckbox.checked;
+ const flip_mode = this.flipModeSelect.value; // "never", "always", or "endgame"
const knock_penalty = this.knockPenaltyCheckbox.checked;
// Joker mode (radio buttons)
@@ -475,7 +511,7 @@ class GolfGame {
decks,
rounds,
initial_flips,
- flip_on_discard,
+ flip_mode,
knock_penalty,
use_jokers,
lucky_swing,
@@ -757,6 +793,15 @@ class GolfGame {
flipCard(position) {
this.send({ type: 'flip_card', position });
this.waitingForFlip = false;
+ this.flipIsOptional = false;
+ }
+
+ skipFlip() {
+ if (!this.flipIsOptional) return;
+ this.send({ type: 'skip_flip' });
+ this.waitingForFlip = false;
+ this.flipIsOptional = false;
+ this.hideToast();
}
// Fire-and-forget animation triggers based on state changes
@@ -1164,6 +1209,9 @@ class GolfGame {
this.lobbyScreen.classList.remove('active');
this.waitingScreen.classList.remove('active');
this.gameScreen.classList.remove('active');
+ if (this.rulesScreen) {
+ this.rulesScreen.classList.remove('active');
+ }
screen.classList.add('active');
}
@@ -1566,6 +1614,13 @@ class GolfGame {
this.discardBtn.classList.remove('disabled');
}
+ // Show/hide skip flip button (only when flip is optional in endgame mode)
+ if (this.waitingForFlip && this.flipIsOptional) {
+ this.skipFlipBtn.classList.remove('hidden');
+ } else {
+ this.skipFlipBtn.classList.add('hidden');
+ }
+
// Update scoreboard panel
this.updateScorePanel();
}
diff --git a/client/index.html b/client/index.html
index 93ffe81..0dc4e33 100644
--- a/client/index.html
+++ b/client/index.html
@@ -12,6 +12,7 @@
diff --git a/client/style.css b/client/style.css
index 8a31dfc..0bcbefb 100644
--- a/client/style.css
+++ b/client/style.css
@@ -2151,3 +2151,279 @@ input::placeholder {
padding: 12px 20px;
}
}
+
+/* ===========================================
+ RULES SCREEN
+ =========================================== */
+
+#rules-screen {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+.rules-container {
+ background: rgba(0, 0, 0, 0.3);
+ border-radius: 12px;
+ padding: 25px 35px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.rules-container h1 {
+ text-align: center;
+ margin-bottom: 30px;
+ color: #f4a460;
+ font-size: 2rem;
+}
+
+.rules-container .back-btn {
+ margin-bottom: 20px;
+}
+
+.rules-section {
+ margin-bottom: 35px;
+ padding-bottom: 25px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.15);
+}
+
+.rules-section:last-child {
+ border-bottom: none;
+ margin-bottom: 0;
+}
+
+.rules-section h2 {
+ color: #f4a460;
+ font-size: 1.4rem;
+ margin-bottom: 15px;
+ padding-bottom: 8px;
+ border-bottom: 2px solid rgba(244, 164, 96, 0.3);
+}
+
+.rules-section h3 {
+ color: #e8d8c8;
+ font-size: 1.1rem;
+ margin: 20px 0 10px 0;
+}
+
+.rules-section h4 {
+ color: #d4c4b4;
+ font-size: 1rem;
+ margin: 15px 0 8px 0;
+}
+
+.rules-section p {
+ line-height: 1.7;
+ margin-bottom: 12px;
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.rules-section ul {
+ margin-left: 20px;
+ margin-bottom: 15px;
+}
+
+.rules-section li {
+ line-height: 1.7;
+ margin-bottom: 8px;
+ color: rgba(255, 255, 255, 0.85);
+}
+
+/* Rules table */
+.rules-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 15px 0;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.rules-table th,
+.rules-table td {
+ padding: 12px 15px;
+ text-align: left;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.rules-table th {
+ background: rgba(244, 164, 96, 0.2);
+ color: #f4a460;
+ font-weight: 600;
+}
+
+.rules-table tr:last-child td {
+ border-bottom: none;
+}
+
+.value-negative {
+ color: #4ade80;
+ font-weight: 700;
+}
+
+.value-low {
+ color: #86efac;
+}
+
+.value-zero {
+ color: #fbbf24;
+}
+
+.value-high {
+ color: #f87171;
+ font-weight: 600;
+}
+
+/* Rules example box */
+.rules-example {
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 8px;
+ padding: 15px 20px;
+ margin: 15px 0;
+}
+
+.rules-example h4 {
+ margin-top: 0;
+ color: #f4a460;
+}
+
+.rules-example pre {
+ font-family: 'Courier New', monospace;
+ font-size: 0.9rem;
+ line-height: 1.5;
+ color: rgba(255, 255, 255, 0.9);
+ white-space: pre-wrap;
+ margin: 0;
+}
+
+.rules-warning {
+ background: rgba(239, 68, 68, 0.15);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: 6px;
+ padding: 12px 15px;
+ color: #fca5a5;
+}
+
+/* Rules case boxes */
+.rules-case {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ padding: 15px;
+ margin: 15px 0;
+ border-left: 3px solid rgba(244, 164, 96, 0.5);
+}
+
+.rules-case h4 {
+ margin-top: 0;
+}
+
+/* Flip mode boxes */
+.rules-mode {
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 10px;
+ padding: 20px;
+ margin: 20px 0;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.rules-mode h3 {
+ margin-top: 0;
+ color: #f4a460;
+}
+
+.mode-summary {
+ background: rgba(244, 164, 96, 0.15);
+ border-radius: 6px;
+ padding: 10px 15px;
+ font-weight: 600;
+ color: #f4a460;
+ margin-bottom: 15px;
+}
+
+/* FAQ items */
+.faq-item {
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 8px;
+ padding: 15px 20px;
+ margin: 15px 0;
+ border-left: 3px solid #3b82f6;
+}
+
+.faq-item h4 {
+ margin: 0 0 10px 0;
+ color: #93c5fd;
+}
+
+.faq-item p {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.85);
+}
+
+/* Rules link button in lobby */
+.btn-link {
+ background: transparent;
+ border: none;
+ color: #f4a460;
+ text-decoration: underline;
+ cursor: pointer;
+ font-size: 0.95rem;
+ margin-bottom: 15px;
+}
+
+.btn-link:hover {
+ color: #fbbf24;
+}
+
+/* Select option styling in advanced options */
+.select-option {
+ margin-bottom: 12px;
+}
+
+.select-option label {
+ display: block;
+ margin-bottom: 5px;
+ color: #f4a460;
+ font-size: 0.9rem;
+}
+
+.select-option select {
+ width: 100%;
+ padding: 8px 10px;
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 6px;
+ color: white;
+ font-size: 0.85rem;
+}
+
+.select-option .rule-desc {
+ display: block;
+ margin-top: 4px;
+ font-size: 0.75rem;
+ color: rgba(255, 255, 255, 0.5);
+}
+
+/* Mobile adjustments for rules */
+@media (max-width: 600px) {
+ .rules-container {
+ padding: 20px;
+ }
+
+ .rules-container h1 {
+ font-size: 1.6rem;
+ }
+
+ .rules-section h2 {
+ font-size: 1.2rem;
+ }
+
+ .rules-table th,
+ .rules-table td {
+ padding: 8px 10px;
+ font-size: 0.9rem;
+ }
+
+ .rules-example pre {
+ font-size: 0.8rem;
+ }
+}
diff --git a/server/RULES.md b/server/RULES.md
index 37620b1..37f7305 100644
--- a/server/RULES.md
+++ b/server/RULES.md
@@ -260,13 +260,33 @@ Our implementation supports these optional rule variations. All are **disabled b
| Option | Description | Default |
|--------|-------------|---------|
| `initial_flips` | Cards revealed at start (0, 1, or 2) | 2 |
-| `flip_on_discard` | Must flip a card after discarding from deck | Off |
+| `flip_mode` | What happens when discarding from deck (see below) | `never` |
| `knock_penalty` | +10 if you go out but don't have lowest score | Off |
| `use_jokers` | Add Jokers to deck (-2 points each) | Off |
+### Flip Mode Options
+
+The `flip_mode` setting controls what happens when you draw from the deck and choose to discard (not swap):
+
+| Value | Name | Behavior |
+|-------|------|----------|
+| `never` | **Standard** | No flip when discarding - your turn ends immediately. This is the classic rule. |
+| `always` | **Speed Golf** | Must flip one face-down card when discarding. Accelerates the game by revealing more information each turn. |
+| `endgame` | **Suspense** | May *optionally* flip if any player has ≤1 face-down card. Creates tension near the end of rounds. |
+
+**Standard (never):** When you draw from the deck and choose not to use the card, simply discard it and your turn ends.
+
+**Speed Golf (always):** When you discard from the deck, you must also flip one of your face-down cards. This accelerates the game by revealing more information each turn, leading to faster rounds.
+
+**Suspense (endgame):** When any player has only 1 (or 0) face-down cards remaining, discarding from the deck gives you the *option* to flip a card. This creates tension near the end of rounds - do you reveal more to improve your position, or keep your cards hidden?
+
| Implementation | File |
|----------------|------|
| GameOptions dataclass | `game.py:200-222` |
+| FlipMode enum | `game.py:12-24` |
+| flip_on_discard property | `game.py:449-470` |
+| flip_is_optional property | `game.py:472-479` |
+| skip_flip_and_end_turn() | `game.py:520-540` |
## Point Modifiers
@@ -530,7 +550,11 @@ Draw Phase:
│ └── Swap at position
└── choose_swap_or_discard() returns None
└── Discard drawn card
- └── flip_on_discard? -> choose_flip_after_discard()
+ └── flip_on_discard?
+ ├── flip_mode="always" -> MUST flip (choose_flip_after_discard)
+ └── flip_mode="endgame" -> should_skip_optional_flip()?
+ ├── True -> skip flip, end turn
+ └── False -> flip (choose_flip_after_discard)
```
| Decision Point | Tests |
@@ -737,7 +761,7 @@ Configuration precedence (highest to lowest):
| `DEFAULT_ROUNDS` | `9` | Rounds per game |
| `DEFAULT_INITIAL_FLIPS` | `2` | Cards to flip at start |
| `DEFAULT_USE_JOKERS` | `false` | Enable jokers |
-| `DEFAULT_FLIP_ON_DISCARD` | `false` | Flip after discard |
+| `DEFAULT_FLIP_MODE` | `never` | Flip mode: `never`, `always`, or `endgame` |
### Security
diff --git a/server/ai.py b/server/ai.py
index e7376e2..83daa86 100644
--- a/server/ai.py
+++ b/server/ai.py
@@ -820,6 +820,48 @@ class GolfAI:
return random.choice(face_down)
+ @staticmethod
+ def should_skip_optional_flip(player: Player, profile: CPUProfile, game: Game) -> bool:
+ """
+ Decide whether to skip the optional flip in endgame mode.
+
+ In endgame (Suspense) mode, the flip is optional. AI should generally
+ flip for information, but may skip if:
+ - Already has good information about their hand
+ - Wants to keep cards hidden for suspense
+ - Random unpredictability factor
+
+ Returns True if AI should skip the flip, False if it should flip.
+ """
+ face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
+
+ if not face_down:
+ return True # No cards to flip
+
+ # Very conservative players (low aggression) might skip to keep hidden
+ # But information is usually valuable, so mostly flip
+ skip_chance = 0.1 # Base 10% chance to skip
+
+ # More hidden cards = more value in flipping for information
+ if len(face_down) >= 3:
+ skip_chance = 0.05 # Less likely to skip with many hidden cards
+
+ # If only 1 hidden card, we might skip to keep opponents guessing
+ if len(face_down) == 1:
+ skip_chance = 0.2 + (1.0 - profile.aggression) * 0.2
+
+ # Unpredictable players are more random about this
+ skip_chance += profile.unpredictability * 0.15
+
+ ai_log(f" Optional flip decision: {len(face_down)} face-down cards, skip_chance={skip_chance:.2f}")
+
+ if random.random() < skip_chance:
+ ai_log(f" >> SKIP: choosing not to flip (endgame mode)")
+ return True
+
+ ai_log(f" >> FLIP: choosing to reveal for information")
+ return False
+
@staticmethod
def should_go_out_early(player: Player, game: Game, profile: CPUProfile) -> bool:
"""
@@ -988,21 +1030,57 @@ async def process_cpu_turn(
)
if game.flip_on_discard:
- flip_pos = GolfAI.choose_flip_after_discard(cpu_player, profile)
- game.flip_and_end_turn(cpu_player.id, flip_pos)
+ # Check if flip is optional (endgame mode) and decide whether to skip
+ if game.flip_is_optional:
+ if GolfAI.should_skip_optional_flip(cpu_player, profile, game):
+ game.skip_flip_and_end_turn(cpu_player.id)
- # Log flip decision
- if logger and game_id:
- flipped_card = cpu_player.cards[flip_pos]
- logger.log_move(
- game_id=game_id,
- player=cpu_player,
- is_cpu=True,
- action="flip",
- card=flipped_card,
- position=flip_pos,
- game=game,
- decision_reason=f"flipped card at position {flip_pos}",
- )
+ # Log skip decision
+ if logger and game_id:
+ logger.log_move(
+ game_id=game_id,
+ player=cpu_player,
+ is_cpu=True,
+ action="skip_flip",
+ card=None,
+ game=game,
+ decision_reason="skipped optional flip (endgame mode)",
+ )
+ else:
+ # Choose to flip
+ flip_pos = GolfAI.choose_flip_after_discard(cpu_player, profile)
+ game.flip_and_end_turn(cpu_player.id, flip_pos)
+
+ # Log flip decision
+ if logger and game_id:
+ flipped_card = cpu_player.cards[flip_pos]
+ logger.log_move(
+ game_id=game_id,
+ player=cpu_player,
+ is_cpu=True,
+ action="flip",
+ card=flipped_card,
+ position=flip_pos,
+ game=game,
+ decision_reason=f"flipped card at position {flip_pos} (chose to flip in endgame mode)",
+ )
+ else:
+ # Mandatory flip (always mode)
+ flip_pos = GolfAI.choose_flip_after_discard(cpu_player, profile)
+ game.flip_and_end_turn(cpu_player.id, flip_pos)
+
+ # Log flip decision
+ if logger and game_id:
+ flipped_card = cpu_player.cards[flip_pos]
+ logger.log_move(
+ game_id=game_id,
+ player=cpu_player,
+ is_cpu=True,
+ action="flip",
+ card=flipped_card,
+ position=flip_pos,
+ game=game,
+ decision_reason=f"flipped card at position {flip_pos}",
+ )
await broadcast_callback()
diff --git a/server/config.py b/server/config.py
index c2f83b1..9feda9c 100644
--- a/server/config.py
+++ b/server/config.py
@@ -99,7 +99,7 @@ class GameDefaults:
rounds: int = 9
initial_flips: int = 2
use_jokers: bool = False
- flip_on_discard: bool = False
+ flip_mode: str = "never" # "never", "always", or "endgame"
@dataclass
@@ -160,7 +160,7 @@ class ServerConfig:
rounds=get_env_int("DEFAULT_ROUNDS", 9),
initial_flips=get_env_int("DEFAULT_INITIAL_FLIPS", 2),
use_jokers=get_env_bool("DEFAULT_USE_JOKERS", False),
- flip_on_discard=get_env_bool("DEFAULT_FLIP_ON_DISCARD", False),
+ flip_mode=get_env("DEFAULT_FLIP_MODE", "never"),
),
)
diff --git a/server/constants.py b/server/constants.py
index add2c2a..3031bcd 100644
--- a/server/constants.py
+++ b/server/constants.py
@@ -70,7 +70,7 @@ if _use_config:
DEFAULT_ROUNDS = config.game_defaults.rounds
DEFAULT_INITIAL_FLIPS = config.game_defaults.initial_flips
DEFAULT_USE_JOKERS = config.game_defaults.use_jokers
- DEFAULT_FLIP_ON_DISCARD = config.game_defaults.flip_on_discard
+ DEFAULT_FLIP_MODE = config.game_defaults.flip_mode
else:
MAX_PLAYERS = 6
ROOM_CODE_LENGTH = 4
@@ -78,7 +78,7 @@ else:
DEFAULT_ROUNDS = 9
DEFAULT_INITIAL_FLIPS = 2
DEFAULT_USE_JOKERS = False
- DEFAULT_FLIP_ON_DISCARD = False
+ DEFAULT_FLIP_MODE = "never"
# =============================================================================
diff --git a/server/game.py b/server/game.py
index 93bcfcd..d27f625 100644
--- a/server/game.py
+++ b/server/game.py
@@ -32,6 +32,20 @@ from constants import (
)
+class FlipMode(str, Enum):
+ """
+ Mode for flip-on-discard rule.
+
+ NEVER: No flip when discarding from deck (standard rules)
+ ALWAYS: Must flip when discarding from deck (Speed Golf - faster games)
+ ENDGAME: Optional flip when any player has ≤1 face-down card (Suspense mode)
+ """
+
+ NEVER = "never"
+ ALWAYS = "always"
+ ENDGAME = "endgame"
+
+
class Suit(Enum):
"""Card suits for a standard deck."""
@@ -359,8 +373,8 @@ class GameOptions:
"""
# --- Standard Options ---
- flip_on_discard: bool = False
- """If True, player must flip a face-down card after discarding from deck."""
+ flip_mode: str = "never"
+ """Flip mode when discarding from deck: 'never', 'always', or 'endgame'."""
initial_flips: int = 2
"""Number of cards each player reveals at round start (0, 1, or 2)."""
@@ -448,8 +462,32 @@ class Game:
@property
def flip_on_discard(self) -> bool:
- """Convenience property for flip_on_discard option."""
- return self.options.flip_on_discard
+ """
+ Whether current turn requires/allows a flip after discard.
+
+ Returns True if:
+ - flip_mode is 'always' (Speed Golf)
+ - flip_mode is 'endgame' AND any player has ≤1 face-down card (Suspense)
+ """
+ if self.options.flip_mode == FlipMode.ALWAYS.value:
+ return True
+ if self.options.flip_mode == FlipMode.ENDGAME.value:
+ # Check if any player has ≤1 face-down card
+ for player in self.players:
+ face_down_count = sum(1 for c in player.cards if not c.face_up)
+ if face_down_count <= 1:
+ return True
+ return False
+ return False # "never"
+
+ @property
+ def flip_is_optional(self) -> bool:
+ """
+ Whether the flip is optional (endgame mode) vs mandatory (always mode).
+
+ In endgame mode, player can choose to skip the flip.
+ """
+ return self.options.flip_mode == FlipMode.ENDGAME.value and self.flip_on_discard
def get_card_values(self) -> dict[str, int]:
"""
@@ -817,6 +855,29 @@ class Game:
self._check_end_turn(player)
return True
+ def skip_flip_and_end_turn(self, player_id: str) -> bool:
+ """
+ Skip optional flip and end turn (endgame mode only).
+
+ In endgame mode (flip_mode='endgame'), the flip is optional,
+ so players can choose to skip it and end their turn immediately.
+
+ Args:
+ player_id: ID of the player skipping the flip.
+
+ Returns:
+ True if skip was valid and turn ended, False otherwise.
+ """
+ if not self.flip_is_optional:
+ return False
+
+ player = self.current_player()
+ if not player or player.id != player_id:
+ return False
+
+ self._check_end_turn(player)
+ return True
+
# -------------------------------------------------------------------------
# Turn & Round Flow (Internal)
# -------------------------------------------------------------------------
@@ -1002,8 +1063,10 @@ class Game:
# Build active rules list for display
active_rules = []
if self.options:
- if self.options.flip_on_discard:
- active_rules.append("Flip on Discard")
+ if self.options.flip_mode == FlipMode.ALWAYS.value:
+ active_rules.append("Speed Golf")
+ elif self.options.flip_mode == FlipMode.ENDGAME.value:
+ active_rules.append("Suspense")
if self.options.knock_penalty:
active_rules.append("Knock Penalty")
if self.options.use_jokers and not self.options.lucky_swing and not self.options.eagle_eye:
@@ -1043,6 +1106,8 @@ class Game:
),
"initial_flips": self.options.initial_flips,
"flip_on_discard": self.flip_on_discard,
+ "flip_mode": self.options.flip_mode,
+ "flip_is_optional": self.flip_is_optional,
"card_values": self.get_card_values(),
"active_rules": active_rules,
}
diff --git a/server/game_log.py b/server/game_log.py
index a239c5f..da32aa3 100644
--- a/server/game_log.py
+++ b/server/game_log.py
@@ -70,7 +70,7 @@ class GameLogger:
"""Log start of a new game. Returns game_id."""
game_id = str(uuid.uuid4())
options_dict = {
- "flip_on_discard": options.flip_on_discard,
+ "flip_mode": options.flip_mode,
"initial_flips": options.initial_flips,
"knock_penalty": options.knock_penalty,
"use_jokers": options.use_jokers,
diff --git a/server/main.py b/server/main.py
index 1013b9d..681a025 100644
--- a/server/main.py
+++ b/server/main.py
@@ -560,7 +560,7 @@ async def websocket_endpoint(websocket: WebSocket):
# Build game options
options = GameOptions(
# Standard options
- flip_on_discard=data.get("flip_on_discard", False),
+ flip_mode=data.get("flip_mode", "never"),
initial_flips=max(0, min(2, data.get("initial_flips", 2))),
knock_penalty=data.get("knock_penalty", False),
use_jokers=data.get("use_jokers", False),
@@ -656,18 +656,19 @@ async def websocket_endpoint(websocket: WebSocket):
await broadcast_game_state(current_room)
if current_room.game.flip_on_discard:
- # Version 1: Check if player has face-down cards to flip
+ # Check if player has face-down cards to flip
player = current_room.game.get_player(player_id)
has_face_down = player and any(not c.face_up for c in player.cards)
if has_face_down:
await websocket.send_json({
"type": "can_flip",
+ "optional": current_room.game.flip_is_optional,
})
else:
await check_and_run_cpu_turn(current_room)
else:
- # Version 2 (default): Turn ended, check for CPU
+ # Turn ended, check for CPU
await check_and_run_cpu_turn(current_room)
elif msg_type == "flip_card":
@@ -679,6 +680,14 @@ async def websocket_endpoint(websocket: WebSocket):
await broadcast_game_state(current_room)
await check_and_run_cpu_turn(current_room)
+ elif msg_type == "skip_flip":
+ if not current_room:
+ continue
+
+ if current_room.game.skip_flip_and_end_turn(player_id):
+ await broadcast_game_state(current_room)
+ await check_and_run_cpu_turn(current_room)
+
elif msg_type == "next_round":
if not current_room:
continue
diff --git a/server/score_analysis.py b/server/score_analysis.py
index a93a76b..0e9327e 100644
--- a/server/score_analysis.py
+++ b/server/score_analysis.py
@@ -26,7 +26,7 @@ def run_game_for_scores(num_players: int = 4) -> dict[str, int]:
game.add_player(player)
player_profiles[player.id] = profile
- options = GameOptions(initial_flips=2, flip_on_discard=False, use_jokers=False)
+ options = GameOptions(initial_flips=2, flip_mode="never", use_jokers=False)
game.start_game(num_decks=1, num_rounds=1, options=options)
# Initial flips
diff --git a/server/simulate.py b/server/simulate.py
index a87bf4e..0bcc8ea 100644
--- a/server/simulate.py
+++ b/server/simulate.py
@@ -412,7 +412,7 @@ def run_simulation(
# Default options
options = GameOptions(
initial_flips=2,
- flip_on_discard=False,
+ flip_mode="never",
use_jokers=False,
)
@@ -450,7 +450,7 @@ def run_detailed_game(num_players: int = 4):
options = GameOptions(
initial_flips=2,
- flip_on_discard=False,
+ flip_mode="never",
use_jokers=False,
)
diff --git a/server/test_house_rules.py b/server/test_house_rules.py
index 526bd9d..67ae9b4 100644
--- a/server/test_house_rules.py
+++ b/server/test_house_rules.py
@@ -191,25 +191,30 @@ def get_test_configs() -> list[tuple[str, GameOptions]]:
# Baseline (no house rules)
configs.append(("BASELINE", GameOptions(
initial_flips=2,
- flip_on_discard=False,
+ flip_mode="never",
use_jokers=False,
)))
# === Standard Options ===
- configs.append(("flip_on_discard", GameOptions(
+ configs.append(("flip_mode_always", GameOptions(
initial_flips=2,
- flip_on_discard=True,
+ flip_mode="always",
+ )))
+
+ configs.append(("flip_mode_endgame", GameOptions(
+ initial_flips=2,
+ flip_mode="endgame",
)))
configs.append(("initial_flips=0", GameOptions(
initial_flips=0,
- flip_on_discard=False,
+ flip_mode="never",
)))
configs.append(("initial_flips=1", GameOptions(
initial_flips=1,
- flip_on_discard=False,
+ flip_mode="never",
)))
configs.append(("knock_penalty", GameOptions(
@@ -300,13 +305,13 @@ def get_test_configs() -> list[tuple[str, GameOptions]]:
configs.append(("CLASSIC+ (jokers + flip)", GameOptions(
initial_flips=2,
- flip_on_discard=True,
+ flip_mode="always",
use_jokers=True,
)))
configs.append(("EVERYTHING", GameOptions(
initial_flips=2,
- flip_on_discard=True,
+ flip_mode="always",
knock_penalty=True,
use_jokers=True,
lucky_swing=True,
@@ -472,8 +477,8 @@ def print_expected_effects(results: list[RuleTestResult]):
status = "✓" if diff > 0 else "?"
checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status))
- # flip_on_discard might slightly lower scores (more info)
- r = find("flip_on_discard")
+ # flip_mode_always might slightly lower scores (more info)
+ r = find("flip_mode_always")
if r and r.scores:
diff = r.mean_score - baseline.mean_score
expected = "SIMILAR or lower"