Additional flip on discard variant - endgame and updated rules.md and new rules page.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
108
server/ai.py
108
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()
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user