Additional flip on discard variant - endgame and updated rules.md and new rules page.

This commit is contained in:
Aaron D. Lee
2026-01-26 01:01:08 -05:00
parent e9909fa967
commit 67021b2b51
14 changed files with 771 additions and 54 deletions

View File

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

View File

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

View File

@@ -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"),
),
)

View File

@@ -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"
# =============================================================================

View File

@@ -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,
}

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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