Additional flip on discard variant - endgame and updated rules.md and new rules page.
This commit is contained in:
parent
e9909fa967
commit
67021b2b51
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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.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();
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
276
client/style.css
276
client/style.css
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
108
server/ai.py
108
server/ai.py
@ -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,21 +1030,57 @@ async def process_cpu_turn(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if game.flip_on_discard:
|
if game.flip_on_discard:
|
||||||
flip_pos = GolfAI.choose_flip_after_discard(cpu_player, profile)
|
# Check if flip is optional (endgame mode) and decide whether to skip
|
||||||
game.flip_and_end_turn(cpu_player.id, flip_pos)
|
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
|
# Log skip decision
|
||||||
if logger and game_id:
|
if logger and game_id:
|
||||||
flipped_card = cpu_player.cards[flip_pos]
|
logger.log_move(
|
||||||
logger.log_move(
|
game_id=game_id,
|
||||||
game_id=game_id,
|
player=cpu_player,
|
||||||
player=cpu_player,
|
is_cpu=True,
|
||||||
is_cpu=True,
|
action="skip_flip",
|
||||||
action="flip",
|
card=None,
|
||||||
card=flipped_card,
|
game=game,
|
||||||
position=flip_pos,
|
decision_reason="skipped optional flip (endgame mode)",
|
||||||
game=game,
|
)
|
||||||
decision_reason=f"flipped card at position {flip_pos}",
|
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()
|
await broadcast_callback()
|
||||||
|
|||||||
@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user