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

@ -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 - `blackjack` - Score of exactly 21 becomes 0
### Gameplay Options ### 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 - `use_jokers` - Add Jokers to deck
- `eagle_eye` - Paired Jokers score -8 instead of canceling - `eagle_eye` - Paired Jokers score -8 instead of canceling

View File

@ -138,8 +138,13 @@ class GolfGame {
this.deckRecommendation = document.getElementById('deck-recommendation'); this.deckRecommendation = document.getElementById('deck-recommendation');
this.numRoundsSelect = document.getElementById('num-rounds'); this.numRoundsSelect = document.getElementById('num-rounds');
this.initialFlipsSelect = document.getElementById('initial-flips'); 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'); 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 // House Rules - Point Modifiers
this.superKingsCheckbox = document.getElementById('super-kings'); this.superKingsCheckbox = document.getElementById('super-kings');
this.tenPennyCheckbox = document.getElementById('ten-penny'); this.tenPennyCheckbox = document.getElementById('ten-penny');
@ -170,6 +175,7 @@ class GolfGame {
this.discard = document.getElementById('discard'); this.discard = document.getElementById('discard');
this.discardContent = document.getElementById('discard-content'); this.discardContent = document.getElementById('discard-content');
this.discardBtn = document.getElementById('discard-btn'); this.discardBtn = document.getElementById('discard-btn');
this.skipFlipBtn = document.getElementById('skip-flip-btn');
this.playerCards = document.getElementById('player-cards'); this.playerCards = document.getElementById('player-cards');
this.playerArea = this.playerCards.closest('.player-area'); this.playerArea = this.playerCards.closest('.player-area');
this.swapAnimation = document.getElementById('swap-animation'); this.swapAnimation = document.getElementById('swap-animation');
@ -193,6 +199,7 @@ class GolfGame {
this.deck.addEventListener('click', () => { this.playSound('card'); this.drawFromDeck(); }); this.deck.addEventListener('click', () => { this.playSound('card'); this.drawFromDeck(); });
this.discard.addEventListener('click', () => { this.playSound('card'); this.drawFromDiscard(); }); this.discard.addEventListener('click', () => { this.playSound('card'); this.drawFromDiscard(); });
this.discardBtn.addEventListener('click', () => { this.playSound('card'); this.discardDrawn(); }); 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.nextRoundBtn.addEventListener('click', () => { this.playSound('click'); this.nextRound(); });
this.newGameBtn.addEventListener('click', () => { this.playSound('click'); this.newGame(); }); this.newGameBtn.addEventListener('click', () => { this.playSound('click'); this.newGame(); });
this.addCpuBtn.addEventListener('click', () => { this.playSound('click'); this.showCpuSelect(); }); 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() { connect() {
@ -367,7 +398,12 @@ class GolfGame {
case 'can_flip': case 'can_flip':
this.waitingForFlip = true; this.waitingForFlip = true;
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.showToast('Flip a face-down card', '', 3000);
}
this.renderGame(); this.renderGame();
break; break;
@ -450,7 +486,7 @@ class GolfGame {
const initial_flips = parseInt(this.initialFlipsSelect.value); const initial_flips = parseInt(this.initialFlipsSelect.value);
// Standard options // 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; const knock_penalty = this.knockPenaltyCheckbox.checked;
// Joker mode (radio buttons) // Joker mode (radio buttons)
@ -475,7 +511,7 @@ class GolfGame {
decks, decks,
rounds, rounds,
initial_flips, initial_flips,
flip_on_discard, flip_mode,
knock_penalty, knock_penalty,
use_jokers, use_jokers,
lucky_swing, lucky_swing,
@ -757,6 +793,15 @@ class GolfGame {
flipCard(position) { flipCard(position) {
this.send({ type: 'flip_card', position }); this.send({ type: 'flip_card', position });
this.waitingForFlip = false; 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 // Fire-and-forget animation triggers based on state changes
@ -1164,6 +1209,9 @@ class GolfGame {
this.lobbyScreen.classList.remove('active'); this.lobbyScreen.classList.remove('active');
this.waitingScreen.classList.remove('active'); this.waitingScreen.classList.remove('active');
this.gameScreen.classList.remove('active'); this.gameScreen.classList.remove('active');
if (this.rulesScreen) {
this.rulesScreen.classList.remove('active');
}
screen.classList.add('active'); screen.classList.add('active');
} }
@ -1566,6 +1614,13 @@ class GolfGame {
this.discardBtn.classList.remove('disabled'); 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 // Update scoreboard panel
this.updateScorePanel(); this.updateScorePanel();
} }

View File

@ -12,6 +12,7 @@
<div id="lobby-screen" class="screen active"> <div id="lobby-screen" class="screen active">
<h1>🏌️ Golf</h1> <h1>🏌️ Golf</h1>
<p class="subtitle">6-Card Golf Card Game</p> <p class="subtitle">6-Card Golf Card Game</p>
<button id="rules-btn" class="btn btn-link">View Rules</button>
<div class="form-group"> <div class="form-group">
<label for="player-name">Your Name</label> <label for="player-name">Your Name</label>
@ -99,11 +100,15 @@
<div class="options-category"> <div class="options-category">
<h4>Variants</h4> <h4>Variants</h4>
<div class="checkbox-group"> <div class="checkbox-group">
<label class="checkbox-label"> <div class="select-option">
<input type="checkbox" id="flip-on-discard"> <label for="flip-mode">Flip on Discard</label>
<span>Flip on Discard</span> <select id="flip-mode">
<span class="rule-desc">Flip card when discarding from deck</span> <option value="never">Standard - No flip after discarding</option>
</label> <option value="always">Speed Golf - MUST flip a card after discarding</option>
<option value="endgame">Suspense - MAY flip if anyone has 1 or fewer hidden cards</option>
</select>
<span class="rule-desc">What happens when you draw from deck and discard</span>
</div>
<label class="checkbox-label"> <label class="checkbox-label">
<input type="checkbox" id="knock-penalty"> <input type="checkbox" id="knock-penalty">
<span>Knock Penalty</span> <span>Knock Penalty</span>
@ -235,6 +240,7 @@
<span id="discard-content"></span> <span id="discard-content"></span>
</div> </div>
<button id="discard-btn" class="btn btn-small hidden">Discard</button> <button id="discard-btn" class="btn btn-small hidden">Discard</button>
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
</div> </div>
</div> </div>
</div> </div>
@ -288,6 +294,202 @@
</div> </div>
</div> </div>
<!-- Rules Screen -->
<div id="rules-screen" class="screen">
<div class="rules-container">
<button id="rules-back-btn" class="btn btn-secondary back-btn">← Back</button>
<h1>Game Rules</h1>
<section id="rules-basic" class="rules-section">
<h2>Basic Rules</h2>
<p><strong>6-Card Golf</strong> is a card game where players try to achieve the <strong>lowest score</strong> over multiple rounds ("holes"). Like golf, lower is better!</p>
<ul>
<li>Each player has <strong>6 cards</strong> arranged in a 2-row by 3-column grid</li>
<li>Most cards start <strong>face-down</strong> (hidden from everyone)</li>
<li>On your turn: <strong>draw one card</strong>, then either <strong>swap it</strong> with one of yours or <strong>discard it</strong></li>
<li>When any player reveals <strong>all 6 of their cards</strong>, everyone else gets <strong>one final turn</strong></li>
<li>After all rounds ("holes") are played, the player with the <strong>lowest total score wins</strong></li>
</ul>
</section>
<section id="rules-card-values" class="rules-section">
<h2>Card Values</h2>
<table class="rules-table">
<thead>
<tr><th>Card</th><th>Points</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td>Joker</td><td class="value-negative">-2</td><td>Best card! (requires Jokers to be enabled)</td></tr>
<tr><td>2</td><td class="value-negative">-2</td><td>Excellent - gives you negative points!</td></tr>
<tr><td>Ace (A)</td><td class="value-low">1</td><td>Very low and safe</td></tr>
<tr><td>King (K)</td><td class="value-zero">0</td><td>Zero points - great for making pairs!</td></tr>
<tr><td>3 through 10</td><td>Face value</td><td>3=3 pts, 4=4 pts, ..., 10=10 pts</td></tr>
<tr><td>Jack (J), Queen (Q)</td><td class="value-high">10</td><td>High cards - replace these quickly!</td></tr>
</tbody>
</table>
</section>
<section id="rules-pairing" class="rules-section">
<h2>Column Pairing (IMPORTANT!)</h2>
<p><strong>This is the most important rule to understand:</strong></p>
<p>If both cards in a <strong>vertical column</strong> have the <strong>same rank</strong> (like two Kings, or two 7s), that entire column scores <strong>0 points</strong> - regardless of what the cards are worth individually!</p>
<div class="rules-example">
<h4>Example:</h4>
<pre>
Your 6-card grid:
Col1 Col2 Col3
[K] [5] [7] ← Top row
[K] [3] [9] ← Bottom row
Column 1: K + K = PAIR! = 0 points (not 0+0)
Column 2: 5 + 3 = 8 points
Column 3: 7 + 9 = 16 points
TOTAL: 0 + 8 + 16 = 24 points</pre>
</div>
<p class="rules-warning"><strong>IMPORTANT:</strong> When you pair cards, you get 0 points for that column - even if the cards have negative values! Two 2s paired = 0 points (not -4). Two Jokers paired = 0 points (not -4).</p>
</section>
<section id="rules-turn" class="rules-section">
<h2>Turn Structure (Step by Step)</h2>
<h3>Step 1: Draw a Card</h3>
<p>You MUST draw exactly one card. Choose from:</p>
<ul>
<li><strong>The Deck</strong> (face-down pile) - You don't know what you'll get!</li>
<li><strong>The Discard Pile</strong> (face-up pile) - You can see exactly what card you're taking</li>
</ul>
<h3>Step 2: Use or Discard the Card</h3>
<div class="rules-case">
<h4>If you drew from the DECK:</h4>
<p>You have two options:</p>
<ul>
<li><strong>SWAP:</strong> Replace any one of your 6 cards with the drawn card. The old card goes to the discard pile.</li>
<li><strong>DISCARD:</strong> Put the drawn card directly on the discard pile without using it.</li>
</ul>
</div>
<div class="rules-case">
<h4>If you drew from the DISCARD PILE:</h4>
<p>You MUST swap - you cannot put the same card back on the discard pile.</p>
</div>
</section>
<section id="rules-flip-mode" class="rules-section">
<h2>Flip on Discard Rules (3 Modes)</h2>
<p>This setting affects what happens when you draw from the deck and choose to <strong>discard</strong> (not swap):</p>
<div class="rules-mode">
<h3>Standard Mode (No Flip)</h3>
<p class="mode-summary">Default setting. Discarding ends your turn immediately.</p>
<p><strong>How it works:</strong> When you draw from the deck and decide not to use it, you simply discard it and your turn is over. Nothing else happens.</p>
<p><strong>Best for:</strong> Traditional gameplay, longer games, maximum hidden information.</p>
</div>
<div class="rules-mode">
<h3>Speed Golf Mode (Must Flip)</h3>
<p class="mode-summary">Every discard reveals one of your hidden cards.</p>
<p><strong>How it works:</strong> When you draw from the deck and discard, you MUST also flip over one of your face-down cards. This is mandatory - you cannot skip it.</p>
<p><strong>Why use it:</strong> Games go much faster because more cards get revealed every turn. More information for everyone = more strategic decisions.</p>
<p><strong>Best for:</strong> Quick games, players who like faster-paced action.</p>
</div>
<div class="rules-mode">
<h3>Suspense Mode (Optional Flip Near Endgame)</h3>
<p class="mode-summary">Optional flip activates when any player is close to finishing.</p>
<p><strong>How it works:</strong></p>
<ul>
<li>Early in the round: Discarding ends your turn (like Standard mode)</li>
<li><strong>When ANY player has 1 or fewer face-down cards:</strong> After discarding, you MAY choose to flip one of your hidden cards OR skip the flip</li>
</ul>
<p><strong>Why use it:</strong> Creates dramatic tension near the end of rounds. Do you reveal more to try to improve your score, or keep cards hidden to maintain mystery?</p>
<p><strong>Best for:</strong> Players who enjoy dramatic finishes and tough end-game decisions.</p>
</div>
</section>
<section id="rules-house-rules" class="rules-section">
<h2>House Rules (Optional Variants)</h2>
<h3>Point Modifiers</h3>
<ul>
<li><strong>Super Kings:</strong> Kings are worth -2 points instead of 0 (makes them even better!)</li>
<li><strong>Ten Penny:</strong> 10s are worth only 1 point instead of 10 (makes 10s less scary)</li>
</ul>
<h3>Joker Variants</h3>
<ul>
<li><strong>Standard Jokers:</strong> 2 Jokers per deck, each worth -2 points (paired Jokers = 0 points)</li>
<li><strong>Lucky Swing:</strong> Only 1 Joker in the entire deck, but it's worth -5 points! (Rare and powerful)</li>
<li><strong>Eagle Eye:</strong> Jokers are worth +2 points unpaired, but -4 points when paired (rewards finding both Jokers)</li>
</ul>
<h3>Bonuses & Penalties</h3>
<ul>
<li><strong>Knock Penalty:</strong> If you "go out" (reveal all cards first) but DON'T have the lowest score, you get +10 penalty points. Risk vs reward!</li>
<li><strong>Knock Bonus:</strong> Get -5 points (subtracted from your score) for going out first.</li>
<li><strong>Underdog Bonus:</strong> The player with the lowest score each hole gets -3 points.</li>
<li><strong>Tied Shame:</strong> If you tie with another player's score, both of you get +5 penalty points.</li>
<li><strong>Blackjack:</strong> If your exact score is 21, it becomes 0 instead!</li>
<li><strong>Wolfpack:</strong> If you have exactly 2 pairs of Jacks (all 4 Jacks), you get -5 bonus points.</li>
</ul>
</section>
<section id="rules-faq" class="rules-section">
<h2>Frequently Asked Questions</h2>
<div class="faq-item">
<h4>Q: Can I look at my face-down cards?</h4>
<p>A: No! Once the game starts, you cannot peek at your own face-down cards. You only see them when they get flipped face-up (either by swapping or by the flip-on-discard rule).</p>
</div>
<div class="faq-item">
<h4>Q: Can I swap a face-down card without looking at it first?</h4>
<p>A: Yes! In fact, that's often the best strategy - if you have a card that seems high based on probability, swap it out before you even see it.</p>
</div>
<div class="faq-item">
<h4>Q: What happens when someone reveals all their cards?</h4>
<p>A: Once ANY player has all 6 cards face-up, every other player gets exactly ONE more turn. Then the round ends and scores are calculated.</p>
</div>
<div class="faq-item">
<h4>Q: Do I have to go out (reveal all cards) to win?</h4>
<p>A: No! You can win the round even with face-down cards. The player with the lowest score wins, regardless of how many cards are revealed.</p>
</div>
<div class="faq-item">
<h4>Q: When do pairs count?</h4>
<p>A: Pairs only count in VERTICAL columns (top card + bottom card in the same column). Horizontal or diagonal matches don't create pairs.</p>
</div>
<div class="faq-item">
<h4>Q: Can I make a pair with face-down cards?</h4>
<p>A: Face-down cards are still counted for scoring, but since you can't see them, you're gambling that they might form a pair. At the end of the round, all cards are revealed and pairs are calculated.</p>
</div>
<div class="faq-item">
<h4>Q: What if the deck runs out of cards?</h4>
<p>A: The discard pile (except the top card) is shuffled to create a new deck.</p>
</div>
<div class="faq-item">
<h4>Q: In Suspense mode, when exactly can I flip?</h4>
<p>A: The optional flip activates the moment ANY player (including you) has 1 or fewer face-down cards remaining. From that point until the round ends, whenever you discard from the deck, you'll get the option to flip or skip.</p>
</div>
<div class="faq-item">
<h4>Q: Why would I NOT flip in Suspense mode?</h4>
<p>A: Maybe you have a hidden card you hope is good, and you don't want to reveal a potential disaster. Or maybe you want to keep your opponents guessing about your score. It's a strategic choice!</p>
</div>
</section>
</div>
</div>
</div>
<!-- CPU Select Modal --> <!-- CPU Select Modal -->
<div id="cpu-select-modal" class="modal hidden"> <div id="cpu-select-modal" class="modal hidden">
<div class="modal-content"> <div class="modal-content">

View File

@ -2151,3 +2151,279 @@ input::placeholder {
padding: 12px 20px; 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;
}
}

View File

@ -260,13 +260,33 @@ Our implementation supports these optional rule variations. All are **disabled b
| Option | Description | Default | | Option | Description | Default |
|--------|-------------|---------| |--------|-------------|---------|
| `initial_flips` | Cards revealed at start (0, 1, or 2) | 2 | | `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 | | `knock_penalty` | +10 if you go out but don't have lowest score | Off |
| `use_jokers` | Add Jokers to deck (-2 points each) | 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 | | Implementation | File |
|----------------|------| |----------------|------|
| GameOptions dataclass | `game.py:200-222` | | 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 ## Point Modifiers
@ -530,7 +550,11 @@ Draw Phase:
│ └── Swap at position │ └── Swap at position
└── choose_swap_or_discard() returns None └── choose_swap_or_discard() returns None
└── Discard drawn card └── 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 | | Decision Point | Tests |
@ -737,7 +761,7 @@ Configuration precedence (highest to lowest):
| `DEFAULT_ROUNDS` | `9` | Rounds per game | | `DEFAULT_ROUNDS` | `9` | Rounds per game |
| `DEFAULT_INITIAL_FLIPS` | `2` | Cards to flip at start | | `DEFAULT_INITIAL_FLIPS` | `2` | Cards to flip at start |
| `DEFAULT_USE_JOKERS` | `false` | Enable jokers | | `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 ### Security

View File

@ -820,6 +820,48 @@ class GolfAI:
return random.choice(face_down) 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 @staticmethod
def should_go_out_early(player: Player, game: Game, profile: CPUProfile) -> bool: def should_go_out_early(player: Player, game: Game, profile: CPUProfile) -> bool:
""" """
@ -988,6 +1030,42 @@ async def process_cpu_turn(
) )
if game.flip_on_discard: if game.flip_on_discard:
# 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 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) flip_pos = GolfAI.choose_flip_after_discard(cpu_player, profile)
game.flip_and_end_turn(cpu_player.id, flip_pos) game.flip_and_end_turn(cpu_player.id, flip_pos)

View File

@ -99,7 +99,7 @@ class GameDefaults:
rounds: int = 9 rounds: int = 9
initial_flips: int = 2 initial_flips: int = 2
use_jokers: bool = False use_jokers: bool = False
flip_on_discard: bool = False flip_mode: str = "never" # "never", "always", or "endgame"
@dataclass @dataclass
@ -160,7 +160,7 @@ class ServerConfig:
rounds=get_env_int("DEFAULT_ROUNDS", 9), rounds=get_env_int("DEFAULT_ROUNDS", 9),
initial_flips=get_env_int("DEFAULT_INITIAL_FLIPS", 2), initial_flips=get_env_int("DEFAULT_INITIAL_FLIPS", 2),
use_jokers=get_env_bool("DEFAULT_USE_JOKERS", False), 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_ROUNDS = config.game_defaults.rounds
DEFAULT_INITIAL_FLIPS = config.game_defaults.initial_flips DEFAULT_INITIAL_FLIPS = config.game_defaults.initial_flips
DEFAULT_USE_JOKERS = config.game_defaults.use_jokers 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: else:
MAX_PLAYERS = 6 MAX_PLAYERS = 6
ROOM_CODE_LENGTH = 4 ROOM_CODE_LENGTH = 4
@ -78,7 +78,7 @@ else:
DEFAULT_ROUNDS = 9 DEFAULT_ROUNDS = 9
DEFAULT_INITIAL_FLIPS = 2 DEFAULT_INITIAL_FLIPS = 2
DEFAULT_USE_JOKERS = False 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): class Suit(Enum):
"""Card suits for a standard deck.""" """Card suits for a standard deck."""
@ -359,8 +373,8 @@ class GameOptions:
""" """
# --- Standard Options --- # --- Standard Options ---
flip_on_discard: bool = False flip_mode: str = "never"
"""If True, player must flip a face-down card after discarding from deck.""" """Flip mode when discarding from deck: 'never', 'always', or 'endgame'."""
initial_flips: int = 2 initial_flips: int = 2
"""Number of cards each player reveals at round start (0, 1, or 2).""" """Number of cards each player reveals at round start (0, 1, or 2)."""
@ -448,8 +462,32 @@ class Game:
@property @property
def flip_on_discard(self) -> bool: 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]: def get_card_values(self) -> dict[str, int]:
""" """
@ -817,6 +855,29 @@ class Game:
self._check_end_turn(player) self._check_end_turn(player)
return True 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) # Turn & Round Flow (Internal)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@ -1002,8 +1063,10 @@ class Game:
# Build active rules list for display # Build active rules list for display
active_rules = [] active_rules = []
if self.options: if self.options:
if self.options.flip_on_discard: if self.options.flip_mode == FlipMode.ALWAYS.value:
active_rules.append("Flip on Discard") active_rules.append("Speed Golf")
elif self.options.flip_mode == FlipMode.ENDGAME.value:
active_rules.append("Suspense")
if self.options.knock_penalty: if self.options.knock_penalty:
active_rules.append("Knock Penalty") active_rules.append("Knock Penalty")
if self.options.use_jokers and not self.options.lucky_swing and not self.options.eagle_eye: 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, "initial_flips": self.options.initial_flips,
"flip_on_discard": self.flip_on_discard, "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(), "card_values": self.get_card_values(),
"active_rules": active_rules, "active_rules": active_rules,
} }

View File

@ -70,7 +70,7 @@ class GameLogger:
"""Log start of a new game. Returns game_id.""" """Log start of a new game. Returns game_id."""
game_id = str(uuid.uuid4()) game_id = str(uuid.uuid4())
options_dict = { options_dict = {
"flip_on_discard": options.flip_on_discard, "flip_mode": options.flip_mode,
"initial_flips": options.initial_flips, "initial_flips": options.initial_flips,
"knock_penalty": options.knock_penalty, "knock_penalty": options.knock_penalty,
"use_jokers": options.use_jokers, "use_jokers": options.use_jokers,

View File

@ -560,7 +560,7 @@ async def websocket_endpoint(websocket: WebSocket):
# Build game options # Build game options
options = GameOptions( options = GameOptions(
# Standard options # 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))), initial_flips=max(0, min(2, data.get("initial_flips", 2))),
knock_penalty=data.get("knock_penalty", False), knock_penalty=data.get("knock_penalty", False),
use_jokers=data.get("use_jokers", False), use_jokers=data.get("use_jokers", False),
@ -656,18 +656,19 @@ async def websocket_endpoint(websocket: WebSocket):
await broadcast_game_state(current_room) await broadcast_game_state(current_room)
if current_room.game.flip_on_discard: 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) player = current_room.game.get_player(player_id)
has_face_down = player and any(not c.face_up for c in player.cards) has_face_down = player and any(not c.face_up for c in player.cards)
if has_face_down: if has_face_down:
await websocket.send_json({ await websocket.send_json({
"type": "can_flip", "type": "can_flip",
"optional": current_room.game.flip_is_optional,
}) })
else: else:
await check_and_run_cpu_turn(current_room) await check_and_run_cpu_turn(current_room)
else: else:
# Version 2 (default): Turn ended, check for CPU # Turn ended, check for CPU
await check_and_run_cpu_turn(current_room) await check_and_run_cpu_turn(current_room)
elif msg_type == "flip_card": elif msg_type == "flip_card":
@ -679,6 +680,14 @@ async def websocket_endpoint(websocket: WebSocket):
await broadcast_game_state(current_room) await broadcast_game_state(current_room)
await check_and_run_cpu_turn(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": elif msg_type == "next_round":
if not current_room: if not current_room:
continue continue

View File

@ -26,7 +26,7 @@ def run_game_for_scores(num_players: int = 4) -> dict[str, int]:
game.add_player(player) game.add_player(player)
player_profiles[player.id] = profile 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) game.start_game(num_decks=1, num_rounds=1, options=options)
# Initial flips # Initial flips

View File

@ -412,7 +412,7 @@ def run_simulation(
# Default options # Default options
options = GameOptions( options = GameOptions(
initial_flips=2, initial_flips=2,
flip_on_discard=False, flip_mode="never",
use_jokers=False, use_jokers=False,
) )
@ -450,7 +450,7 @@ def run_detailed_game(num_players: int = 4):
options = GameOptions( options = GameOptions(
initial_flips=2, initial_flips=2,
flip_on_discard=False, flip_mode="never",
use_jokers=False, use_jokers=False,
) )

View File

@ -191,25 +191,30 @@ def get_test_configs() -> list[tuple[str, GameOptions]]:
# Baseline (no house rules) # Baseline (no house rules)
configs.append(("BASELINE", GameOptions( configs.append(("BASELINE", GameOptions(
initial_flips=2, initial_flips=2,
flip_on_discard=False, flip_mode="never",
use_jokers=False, use_jokers=False,
))) )))
# === Standard Options === # === Standard Options ===
configs.append(("flip_on_discard", GameOptions( configs.append(("flip_mode_always", GameOptions(
initial_flips=2, 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( configs.append(("initial_flips=0", GameOptions(
initial_flips=0, initial_flips=0,
flip_on_discard=False, flip_mode="never",
))) )))
configs.append(("initial_flips=1", GameOptions( configs.append(("initial_flips=1", GameOptions(
initial_flips=1, initial_flips=1,
flip_on_discard=False, flip_mode="never",
))) )))
configs.append(("knock_penalty", GameOptions( configs.append(("knock_penalty", GameOptions(
@ -300,13 +305,13 @@ def get_test_configs() -> list[tuple[str, GameOptions]]:
configs.append(("CLASSIC+ (jokers + flip)", GameOptions( configs.append(("CLASSIC+ (jokers + flip)", GameOptions(
initial_flips=2, initial_flips=2,
flip_on_discard=True, flip_mode="always",
use_jokers=True, use_jokers=True,
))) )))
configs.append(("EVERYTHING", GameOptions( configs.append(("EVERYTHING", GameOptions(
initial_flips=2, initial_flips=2,
flip_on_discard=True, flip_mode="always",
knock_penalty=True, knock_penalty=True,
use_jokers=True, use_jokers=True,
lucky_swing=True, lucky_swing=True,
@ -472,8 +477,8 @@ def print_expected_effects(results: list[RuleTestResult]):
status = "" if diff > 0 else "?" status = "" if diff > 0 else "?"
checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status)) checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status))
# flip_on_discard might slightly lower scores (more info) # flip_mode_always might slightly lower scores (more info)
r = find("flip_on_discard") r = find("flip_mode_always")
if r and r.scores: if r and r.scores:
diff = r.mean_score - baseline.mean_score diff = r.mean_score - baseline.mean_score
expected = "SIMILAR or lower" expected = "SIMILAR or lower"