Numerous WebUI animations, improvements, AI fixes, opporitunity cost-based decision logic, etc.
This commit is contained in:
845
server/RULES.md
845
server/RULES.md
@@ -1,6 +1,19 @@
|
||||
# 6-Card Golf Rules
|
||||
|
||||
This document defines the canonical rules implemented in this game engine, based on standard 6-Card Golf rules from [Pagat.com](https://www.pagat.com/draw/golf.html) and [Bicycle Cards](https://bicyclecards.com/how-to-play/six-card-golf).
|
||||
> **Single Source of Truth** for all game rules, variants, and house rules.
|
||||
> This document is the canonical reference - all implementations must match these specifications.
|
||||
|
||||
## Document Structure
|
||||
|
||||
This document follows a **vertical documentation structure**:
|
||||
1. **Rules** - Human-readable game rules
|
||||
2. **Implementation** - Code references (file:line)
|
||||
3. **Tests** - Verification test references
|
||||
4. **Edge Cases** - Documented edge case behaviors
|
||||
|
||||
---
|
||||
|
||||
# Part 1: Core Game Rules
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -12,48 +25,141 @@ Golf is a card game where players try to achieve the **lowest score** over multi
|
||||
- **Deck:** Standard 52-card deck (optionally with 2 Jokers)
|
||||
- **Multiple decks:** For 5+ players, use 2 decks
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| Deck creation | `game.py:89-104` |
|
||||
| Multi-deck support | `game.py:92-103` |
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| Standard 52 cards | `test_game.py:501-504` |
|
||||
| Joker deck 54 cards | `test_game.py:506-509` |
|
||||
| Multi-deck | `test_game.py:516-519` |
|
||||
|
||||
## Setup
|
||||
|
||||
1. Dealer shuffles and deals **6 cards face-down** to each player
|
||||
2. Players arrange cards in a **2 row × 3 column grid**:
|
||||
2. Players arrange cards in a **2 row x 3 column grid**:
|
||||
```
|
||||
[0] [1] [2] ← Top row
|
||||
[3] [4] [5] ← Bottom row
|
||||
[0] [1] [2] <- Top row
|
||||
[3] [4] [5] <- Bottom row
|
||||
```
|
||||
3. Remaining cards form the **draw pile** (face-down)
|
||||
4. Top card of draw pile is flipped to start the **discard pile**
|
||||
5. Each player flips **2 of their cards** face-up (standard rules)
|
||||
5. Each player flips **2 of their cards** face-up (configurable: 0, 1, or 2)
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| Deal 6 cards | `game.py:304-311` |
|
||||
| Start discard pile | `game.py:313-317` |
|
||||
| Initial flip phase | `game.py:326-352` |
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| Initial flip 2 cards | `test_game.py:454-469` |
|
||||
| Initial flip 0 skips phase | `test_game.py:471-478` |
|
||||
| Game starts after all flip | `test_game.py:480-491` |
|
||||
|
||||
---
|
||||
|
||||
## Card Values
|
||||
|
||||
| Card | Points |
|
||||
|------|--------|
|
||||
| Ace | 1 |
|
||||
| 2 | **-2** (negative!) |
|
||||
| 3-10 | Face value |
|
||||
| Jack | 10 |
|
||||
| Queen | 10 |
|
||||
| King | **0** |
|
||||
| Joker | -2 |
|
||||
| Card | Points | Notes |
|
||||
|------|--------|-------|
|
||||
| Ace | 1 | Low card |
|
||||
| **2** | **-2** | Negative! Best non-special card |
|
||||
| 3-10 | Face value | 3=3, 4=4, ..., 10=10 |
|
||||
| Jack | 10 | Face card |
|
||||
| Queen | 10 | Face card |
|
||||
| **King** | **0** | Zero points |
|
||||
| **Joker** | **-2** | Negative (requires `use_jokers` option) |
|
||||
|
||||
### Card Value Quality Tiers
|
||||
|
||||
| Tier | Cards | Strategy |
|
||||
|------|-------|----------|
|
||||
| **Excellent** | Joker (-2), 2 (-2) | Always keep, never pair |
|
||||
| **Good** | King (0) | Safe, good for pairing |
|
||||
| **Decent** | Ace (1) | Low risk |
|
||||
| **Neutral** | 3, 4, 5 | Acceptable |
|
||||
| **Bad** | 6, 7 | Replace when possible |
|
||||
| **Terrible** | 8, 9, 10, J, Q | High priority to replace |
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| DEFAULT_CARD_VALUES | `constants.py:3-18` |
|
||||
| RANK_VALUES derivation | `game.py:40-41` |
|
||||
| get_card_value() | `game.py:44-67` |
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| Ace worth 1 | `test_game.py:29-30` |
|
||||
| Two worth -2 | `test_game.py:32-33` |
|
||||
| 3-10 face value | `test_game.py:35-43` |
|
||||
| Jack worth 10 | `test_game.py:45-46` |
|
||||
| Queen worth 10 | `test_game.py:48-49` |
|
||||
| King worth 0 | `test_game.py:51-52` |
|
||||
| Joker worth -2 | `test_game.py:54-55` |
|
||||
| Card.value() method | `test_game.py:57-63` |
|
||||
| Rank quality classification | `test_analyzer.py:47-74` |
|
||||
|
||||
---
|
||||
|
||||
## Column Pairing
|
||||
|
||||
**Critical rule:** If both cards in a column have the **same rank**, that column scores **0 points** regardless of the individual card values.
|
||||
**Critical Rule:** If both cards in a column have the **same rank**, that column scores **0 points** regardless of the individual card values.
|
||||
|
||||
Example:
|
||||
### Column Positions
|
||||
```
|
||||
Column 0: positions (0, 3)
|
||||
Column 1: positions (1, 4)
|
||||
Column 2: positions (2, 5)
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
**Matched column:**
|
||||
```
|
||||
[K] [5] [7] K-K pair = 0
|
||||
[K] [3] [9] 5+3 = 8, 7+9 = 16
|
||||
Total: 0 + 8 + 16 = 24
|
||||
```
|
||||
|
||||
**Note:** Paired 2s score 0 (not -4). The pair cancels out, it doesn't double the negative.
|
||||
**All columns matched:**
|
||||
```
|
||||
[A] [5] [K] All paired = 0 total
|
||||
[A] [5] [K]
|
||||
```
|
||||
|
||||
### Edge Case: Paired Negative Cards
|
||||
|
||||
> **IMPORTANT:** Paired 2s score **0**, not -4. The pair **cancels** the value, it doesn't **double** it.
|
||||
|
||||
This is a common source of bugs. When two 2s are paired:
|
||||
- Individual values: -2 + -2 = -4
|
||||
- **Paired value: 0** (pair rule overrides)
|
||||
|
||||
The same applies to paired Jokers (standard rules) - they score 0, not -4.
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| Column pair detection | `game.py:158-178` |
|
||||
| Pair cancels to 0 | `game.py:174-175` |
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| Matching column scores 0 | `test_game.py:83-91` |
|
||||
| All columns matched = 0 | `test_game.py:93-98` |
|
||||
| No columns matched = sum | `test_game.py:100-106` |
|
||||
| **Paired 2s = 0 (not -4)** | `test_game.py:108-115` |
|
||||
| Unpaired negatives keep value | `test_game.py:117-124` |
|
||||
|
||||
---
|
||||
|
||||
## Turn Structure
|
||||
|
||||
On your turn:
|
||||
|
||||
### 1. Draw Phase
|
||||
|
||||
Choose ONE:
|
||||
- Draw the **top card from the draw pile** (face-down deck)
|
||||
- Take the **top card from the discard pile** (face-up)
|
||||
@@ -62,7 +168,7 @@ Choose ONE:
|
||||
|
||||
**If you drew from the DECK:**
|
||||
- **Swap:** Replace any card in your grid (old card goes to discard face-up)
|
||||
- **Discard:** Put the drawn card on the discard pile and flip one face-down card
|
||||
- **Discard:** Put the drawn card on the discard pile (optionally flip a face-down card)
|
||||
|
||||
**If you took from the DISCARD PILE:**
|
||||
- **You MUST swap** - you cannot re-discard the same card
|
||||
@@ -74,32 +180,80 @@ Choose ONE:
|
||||
- You **cannot look** at a face-down card before deciding to replace it
|
||||
- When swapping a face-down card, reveal it only as it goes to discard
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| Draw from deck/discard | `game.py:354-384` |
|
||||
| Swap card | `game.py:409-426` |
|
||||
| Cannot re-discard from discard | `game.py:428-433`, `game.py:443-445` |
|
||||
| Discard from deck draw | `game.py:435-460` |
|
||||
| Flip after discard | `game.py:462-476` |
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| Can draw from deck | `test_game.py:200-205` |
|
||||
| Can draw from discard | `test_game.py:207-214` |
|
||||
| Can discard deck draw | `test_game.py:216-221` |
|
||||
| **Cannot discard discard draw** | `test_game.py:223-228` |
|
||||
| Must swap discard draw | `test_game.py:230-238` |
|
||||
| Swap makes card face-up | `test_game.py:240-247` |
|
||||
| Cannot peek before swap | `test_game.py:249-256` |
|
||||
|
||||
---
|
||||
|
||||
## Round End
|
||||
|
||||
### Triggering the Final Turn
|
||||
|
||||
When any player has **all 6 cards face-up**, the round enters "final turn" phase.
|
||||
|
||||
### Final Turn Phase
|
||||
|
||||
- Each **other player** gets exactly **one more turn**
|
||||
- The player who triggered final turn does NOT get another turn
|
||||
- After all players have had their final turn, the round ends
|
||||
|
||||
### Scoring
|
||||
|
||||
1. All remaining face-down cards are revealed
|
||||
2. Calculate each player's score (with column pairing)
|
||||
3. Add round score to total score
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| Check all face-up | `game.py:478-483` |
|
||||
| Final turn phase | `game.py:488-502` |
|
||||
| End round scoring | `game.py:504-555` |
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| Revealing all triggers final turn | `test_game.py:327-341` |
|
||||
| Other players get final turn | `test_game.py:343-358` |
|
||||
| Finisher doesn't get extra turn | `test_game.py:360-373` |
|
||||
| All cards revealed at round end | `test_game.py:375-388` |
|
||||
|
||||
---
|
||||
|
||||
## Winning
|
||||
|
||||
- Standard game: **9 rounds** ("9 holes")
|
||||
- Player with the **lowest total score** wins
|
||||
- Optionally play 18 rounds for a longer game
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| Multi-round tracking | `game.py:557-567` |
|
||||
| Total score accumulation | `game.py:548-549` |
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| Next round resets hands | `test_game.py:398-418` |
|
||||
| Scores accumulate across rounds | `test_game.py:420-444` |
|
||||
|
||||
---
|
||||
|
||||
# House Rules (Optional)
|
||||
# Part 2: House Rules
|
||||
|
||||
Our implementation supports these optional rule variations:
|
||||
Our implementation supports these optional rule variations. All are **disabled by default**.
|
||||
|
||||
## Standard Options
|
||||
|
||||
@@ -110,78 +264,641 @@ Our implementation supports these optional rule variations:
|
||||
| `knock_penalty` | +10 if you go out but don't have lowest score | Off |
|
||||
| `use_jokers` | Add Jokers to deck (-2 points each) | Off |
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| GameOptions dataclass | `game.py:200-222` |
|
||||
|
||||
## Point Modifiers
|
||||
|
||||
| Option | Effect |
|
||||
|--------|--------|
|
||||
| `lucky_swing` | Single Joker worth **-5** (instead of two -2 Jokers) |
|
||||
| `super_kings` | Kings worth **-2** (instead of 0) |
|
||||
| `ten_penny` | 10s worth **1** (instead of 10) |
|
||||
| Option | Effect | Standard Value | Modified Value |
|
||||
|--------|--------|----------------|----------------|
|
||||
| `lucky_swing` | Single Joker in deck | 2 Jokers @ -2 each | 1 Joker @ **-5** |
|
||||
| `super_kings` | Kings are negative | King = 0 | King = **-2** |
|
||||
| `ten_penny` | 10s are low | 10 = 10 | 10 = **1** |
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| LUCKY_SWING_JOKER_VALUE | `constants.py:23` |
|
||||
| SUPER_KINGS_VALUE | `constants.py:21` |
|
||||
| TEN_PENNY_VALUE | `constants.py:22` |
|
||||
| Value application | `game.py:58-66` |
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| Super Kings -2 | `test_game.py:142-149` |
|
||||
| Ten Penny | `test_game.py:151-158` |
|
||||
| Lucky Swing Joker -5 | `test_game.py:160-173` |
|
||||
| Lucky Swing single joker | `test_game.py:511-514` |
|
||||
|
||||
## Bonuses & Penalties
|
||||
|
||||
| Option | Effect |
|
||||
|--------|--------|
|
||||
| `knock_bonus` | First to reveal all cards gets **-5** bonus |
|
||||
| `underdog_bonus` | Lowest scorer each round gets **-3** |
|
||||
| `tied_shame` | Tying another player's score = **+5** penalty to both |
|
||||
| `blackjack` | Exact score of 21 becomes **0** |
|
||||
| Option | Effect | When Applied |
|
||||
|--------|--------|--------------|
|
||||
| `knock_bonus` | First to reveal all cards gets **-5** | Round end |
|
||||
| `underdog_bonus` | Lowest scorer each round gets **-3** | Round end |
|
||||
| `tied_shame` | Tying another player's score = **+5** penalty to both | Round end |
|
||||
| `blackjack` | Exact score of 21 becomes **0** | Round end |
|
||||
| `wolfpack` | 2 pairs of Jacks = **-5** bonus | Scoring |
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| Blackjack 21->0 | `game.py:513-517` |
|
||||
| Knock penalty | `game.py:519-525` |
|
||||
| Knock bonus | `game.py:527-531` |
|
||||
| Underdog bonus | `game.py:533-538` |
|
||||
| Tied shame | `game.py:540-546` |
|
||||
| Wolfpack | `game.py:180-182` |
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| Blackjack 21 becomes 0 | `test_game.py:175-183` |
|
||||
| House rules integration | `test_house_rules.py` (full file) |
|
||||
|
||||
## Special Rules
|
||||
|
||||
| Option | Effect |
|
||||
|--------|--------|
|
||||
| `eagle_eye` | Jokers worth **+2 unpaired**, **-4 paired** (spot the pair!) |
|
||||
| `eagle_eye` | Jokers worth **+2 unpaired**, **-4 paired** (reward spotting pairs) |
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| Eagle eye unpaired value | `game.py:60-61` |
|
||||
| Eagle eye paired value | `game.py:169-173` |
|
||||
|
||||
---
|
||||
|
||||
# Game Theory Notes
|
||||
# Part 3: AI Decision Making
|
||||
|
||||
## Expected Turn Count
|
||||
## AI Profiles
|
||||
|
||||
With standard rules (2 initial flips):
|
||||
- Start: 2 face-up, 4 face-down
|
||||
- Each turn reveals 1 card (swap or discard+flip)
|
||||
- **Minimum turns to go out:** 4
|
||||
- **Typical range:** 4-8 turns per player per round
|
||||
8 distinct AI personalities with different play styles:
|
||||
|
||||
## Strategic Considerations
|
||||
| Name | Style | Swap Threshold | Pair Hope | Aggression |
|
||||
|------|-------|----------------|-----------|------------|
|
||||
| Sofia | Calculated & Patient | 4 | 0.2 | 0.2 |
|
||||
| Maya | Aggressive Closer | 6 | 0.4 | 0.85 |
|
||||
| Priya | Pair Hunter | 7 | 0.8 | 0.5 |
|
||||
| Marcus | Steady Eddie | 5 | 0.35 | 0.4 |
|
||||
| Kenji | Risk Taker | 8 | 0.7 | 0.75 |
|
||||
| Diego | Chaotic Gambler | 6 | 0.5 | 0.6 |
|
||||
| River | Adaptive Strategist | 5 | 0.45 | 0.55 |
|
||||
| Sage | Sneaky Finisher | 5 | 0.3 | 0.9 |
|
||||
|
||||
### Good Cards (keep these)
|
||||
- **Jokers** (-2 or -5): Best cards in the game
|
||||
- **2s** (-2): Second best, but don't pair them!
|
||||
- **Kings** (0): Safe, good for pairing
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| CPUProfile dataclass | `ai.py:164-182` |
|
||||
| CPU_PROFILES list | `ai.py:186-253` |
|
||||
|
||||
## Key AI Decision Functions
|
||||
|
||||
### should_take_discard()
|
||||
|
||||
Decides whether to take from discard pile or draw from deck.
|
||||
|
||||
**Logic priority:**
|
||||
1. Always take Jokers (and pair if Eagle Eye)
|
||||
2. Always take Kings
|
||||
3. Take 10s if ten_penny enabled
|
||||
4. Take cards that complete a column pair (**except negative cards**)
|
||||
5. Take low cards based on game phase threshold
|
||||
6. Consider end-game pressure
|
||||
7. Take if we have worse visible cards
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| should_take_discard() | `ai.py:333-412` |
|
||||
| Negative card pair avoidance | `ai.py:365-374` |
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| Maya doesn't take 10 with good hand | `test_maya_bug.py:52-83` |
|
||||
| Unpredictability doesn't take bad cards | `test_maya_bug.py:85-116` |
|
||||
| Pair potential respected | `test_maya_bug.py:289-315` |
|
||||
|
||||
### choose_swap_or_discard()
|
||||
|
||||
Decides whether to swap the drawn card into hand or discard it.
|
||||
|
||||
**Logic priority:**
|
||||
1. Eagle Eye: Pair Jokers if visible match exists
|
||||
2. Check for column pair opportunity (**except negative cards**)
|
||||
3. Find best swap among BAD face-up cards (positive value)
|
||||
4. Consider Blackjack (21) pursuit
|
||||
5. Swap excellent cards into face-down positions
|
||||
6. Apply profile-based thresholds
|
||||
|
||||
**Critical:** When placing cards into face-down positions, the AI must avoid creating wasteful pairs with visible negative cards.
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| choose_swap_or_discard() | `ai.py:414-536` |
|
||||
| Negative card pair avoidance | `ai.py:441-446` |
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| Don't discard excellent cards | `test_maya_bug.py:179-209` |
|
||||
| Full Maya bug scenario | `test_maya_bug.py:211-254` |
|
||||
|
||||
---
|
||||
|
||||
# Part 4: Edge Cases & Known Issues
|
||||
|
||||
## Edge Case: Pairing Negative Cards
|
||||
|
||||
**Problem:** Pairing 2s or Jokers wastes their negative value.
|
||||
- Unpaired 2: contributes -2 to score
|
||||
- Paired 2s: contribute 0 to score (lost 2 points!)
|
||||
|
||||
**AI Safeguards:**
|
||||
1. `should_take_discard()`: Only considers pairing if `discard_value > 0`
|
||||
2. `choose_swap_or_discard()`: Sets `should_pair = drawn_value > 0`
|
||||
3. `filter_bad_pair_positions()`: Filters out positions that would create wasteful pairs when placing negative cards into face-down positions
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| get_column_partner_position() | `ai.py:163-168` |
|
||||
| filter_bad_pair_positions() | `ai.py:171-213` |
|
||||
| Applied in choose_swap_or_discard | `ai.py:517`, `ai.py:538` |
|
||||
| Applied in forced swap | `ai.py:711-713` |
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| Paired 2s = 0 (game logic) | `test_game.py:108-115` |
|
||||
| AI avoids pairing logic | `ai.py:365-374`, `ai.py:441-446` |
|
||||
| Filter with visible two | `test_maya_bug.py:320-347` |
|
||||
| Filter allows positive pairs | `test_maya_bug.py:349-371` |
|
||||
| Choose swap avoids 2 pairs | `test_maya_bug.py:373-401` |
|
||||
| Forced swap avoids 2 pairs | `test_maya_bug.py:403-425` |
|
||||
| Fallback when all bad | `test_maya_bug.py:427-451` |
|
||||
|
||||
## Edge Case: Forced Swap from Discard
|
||||
|
||||
When drawing from discard pile and `choose_swap_or_discard()` returns `None` (discard), the AI is forced to swap anyway. The fallback picks randomly from face-down positions, or finds the worst face-up card.
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| Forced swap fallback | `ai.py:665-686` |
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| Forced swap uses house rules | `test_maya_bug.py:143-177` |
|
||||
| All face-up finds worst | `test_maya_bug.py:260-287` |
|
||||
|
||||
## Edge Case: Deck Exhaustion
|
||||
|
||||
When the deck is empty, the discard pile (except top card) is reshuffled back into the deck.
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| Reshuffle discard pile | `game.py:386-407` |
|
||||
|
||||
## Edge Case: Empty Discard Pile
|
||||
|
||||
Cannot draw from empty discard pile.
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| Empty discard returns None | `test_game.py:558-569` |
|
||||
|
||||
---
|
||||
|
||||
# Part 5: Test Coverage Summary
|
||||
|
||||
## Test Files
|
||||
|
||||
| File | Tests | Focus |
|
||||
|------|-------|-------|
|
||||
| `test_game.py` | 44 | Core game rules |
|
||||
| `test_house_rules.py` | 10+ | House rule integration |
|
||||
| `test_analyzer.py` | 18 | AI decision evaluation |
|
||||
| `test_maya_bug.py` | 18 | Bug regression & AI edge cases |
|
||||
|
||||
**Total: 83+ tests**
|
||||
|
||||
## Coverage by Category
|
||||
|
||||
| Category | Tests | Files | Status |
|
||||
|----------|-------|-------|--------|
|
||||
| Card Values | 8 | `test_game.py`, `test_analyzer.py` | Complete |
|
||||
| Column Pairing | 5 | `test_game.py` | Complete |
|
||||
| House Rules Scoring | 4 | `test_game.py` | Complete |
|
||||
| Draw/Discard Mechanics | 7 | `test_game.py` | Complete |
|
||||
| Turn Flow | 4 | `test_game.py` | Complete |
|
||||
| Round End | 4 | `test_game.py` | Complete |
|
||||
| Multi-Round | 2 | `test_game.py` | Complete |
|
||||
| Initial Flip | 3 | `test_game.py` | Complete |
|
||||
| Deck Management | 4 | `test_game.py` | Complete |
|
||||
| Edge Cases | 3 | `test_game.py` | Complete |
|
||||
| Take Discard Evaluation | 6 | `test_analyzer.py` | Complete |
|
||||
| Swap Evaluation | 6 | `test_analyzer.py` | Complete |
|
||||
| House Rules Evaluation | 2 | `test_analyzer.py` | Complete |
|
||||
| Maya Bug Regression | 6 | `test_maya_bug.py` | Complete |
|
||||
| AI Edge Cases | 3 | `test_maya_bug.py` | Complete |
|
||||
| Bad Pair Avoidance | 5 | `test_maya_bug.py` | Complete |
|
||||
|
||||
## Test Plan: Critical Paths
|
||||
|
||||
### Game State Transitions
|
||||
|
||||
```
|
||||
WAITING -> INITIAL_FLIP -> PLAYING -> FINAL_TURN -> ROUND_OVER -> GAME_OVER
|
||||
| ^
|
||||
v (initial_flips=0) |
|
||||
+-------------------------+
|
||||
```
|
||||
|
||||
| Transition | Trigger | Tests |
|
||||
|------------|---------|-------|
|
||||
| WAITING -> INITIAL_FLIP | start_game() | `test_game.py:454-469` |
|
||||
| WAITING -> PLAYING | start_game(initial_flips=0) | `test_game.py:471-478` |
|
||||
| INITIAL_FLIP -> PLAYING | All players flip | `test_game.py:480-491` |
|
||||
| PLAYING -> FINAL_TURN | Player all face-up | `test_game.py:327-341` |
|
||||
| FINAL_TURN -> ROUND_OVER | All final turns done | `test_game.py:343-358` |
|
||||
| ROUND_OVER -> PLAYING | start_next_round() | `test_game.py:398-418` |
|
||||
| ROUND_OVER -> GAME_OVER | Final round complete | `test_game.py:420-444` |
|
||||
|
||||
### AI Decision Tree
|
||||
|
||||
```
|
||||
Draw Phase:
|
||||
├── should_take_discard() returns True
|
||||
│ └── Draw from discard pile
|
||||
│ └── MUST swap (can_discard_drawn=False)
|
||||
└── should_take_discard() returns False
|
||||
└── Draw from deck
|
||||
├── choose_swap_or_discard() returns position
|
||||
│ └── Swap at position
|
||||
└── choose_swap_or_discard() returns None
|
||||
└── Discard drawn card
|
||||
└── flip_on_discard? -> choose_flip_after_discard()
|
||||
```
|
||||
|
||||
| Decision Point | Tests |
|
||||
|----------------|-------|
|
||||
| Take Joker/King from discard | `test_analyzer.py:96-114` |
|
||||
| Don't take bad cards | `test_maya_bug.py:52-116` |
|
||||
| Swap excellent cards | `test_maya_bug.py:179-209` |
|
||||
| Avoid pairing negatives | `test_maya_bug.py:320-451` |
|
||||
| Forced swap from discard | `test_maya_bug.py:143-177`, `test_maya_bug.py:403-425` |
|
||||
|
||||
### Scoring Edge Cases
|
||||
|
||||
| Scenario | Expected | Test |
|
||||
|----------|----------|------|
|
||||
| Paired 2s | 0 (not -4) | `test_game.py:108-115` |
|
||||
| Paired Jokers (standard) | 0 | Implicit |
|
||||
| Paired Jokers (eagle_eye) | -4 | `game.py:169-173` |
|
||||
| Unpaired negative cards | -2 each | `test_game.py:117-124` |
|
||||
| All columns matched | 0 total | `test_game.py:93-98` |
|
||||
| Blackjack (21) | 0 | `test_game.py:175-183` |
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
cd server && python -m pytest -v
|
||||
|
||||
# Run specific test file
|
||||
python -m pytest test_game.py -v
|
||||
|
||||
# Run specific test class
|
||||
python -m pytest test_game.py::TestCardValues -v
|
||||
|
||||
# Run with coverage
|
||||
python -m pytest --cov=. --cov-report=html
|
||||
|
||||
# Run tests matching pattern
|
||||
python -m pytest -k "pair" -v
|
||||
```
|
||||
|
||||
## Test Quality Checklist
|
||||
|
||||
- [x] All card values verified against RULES.md
|
||||
- [x] Column pairing logic tested (including negatives)
|
||||
- [x] House rules tested individually
|
||||
- [x] Draw/discard constraints enforced
|
||||
- [x] Turn flow and player validation
|
||||
- [x] Round end and final turn logic
|
||||
- [x] Multi-round score accumulation
|
||||
- [x] AI decision quality evaluation
|
||||
- [x] Bug regression tests for Maya bug
|
||||
- [x] AI avoids wasteful negative card pairs
|
||||
|
||||
## Disadvantageous Moves (AI Quality Metrics)
|
||||
|
||||
### Definition: "Dumb Moves"
|
||||
|
||||
Moves that are objectively suboptimal and should occur at **minimal background noise level** (< 1% of opportunities).
|
||||
|
||||
| Move Type | Severity | Expected Prevalence | Test Coverage |
|
||||
|-----------|----------|---------------------|---------------|
|
||||
| **Discarding Joker/2** | Blunder | 0% | `test_maya_bug.py:179-209` |
|
||||
| **Discarding King** | Mistake | 0% | `test_analyzer.py:183-192` |
|
||||
| **Taking 10/J/Q without pair** | Blunder | 0% | `test_maya_bug.py:52-116` |
|
||||
| **Pairing negative cards** | Mistake | 0% | `test_maya_bug.py:373-401` |
|
||||
| **Swapping good card for bad** | Mistake | 0% | `test_analyzer.py:219-237` |
|
||||
|
||||
### Definition: "Questionable Moves"
|
||||
|
||||
Moves that may be suboptimal but have legitimate strategic reasons. Should be < 5% of opportunities.
|
||||
|
||||
| Move Type | When Acceptable | Monitoring |
|
||||
|-----------|-----------------|------------|
|
||||
| Not taking low card (3-5) | Pair hunting, early game | Profile-based |
|
||||
| Discarding medium card (4-6) | Full hand, pair potential | Context check |
|
||||
| Going out with high score | Pressure, knock_bonus | Threshold based |
|
||||
|
||||
### AI Quality Assertions
|
||||
|
||||
These assertions should pass when running extended simulations:
|
||||
|
||||
```python
|
||||
# In test suite or simulation
|
||||
def test_ai_quality_metrics():
|
||||
"""Run N games and verify dumb moves are at noise level."""
|
||||
stats = run_simulation(games=1000)
|
||||
|
||||
# ZERO tolerance blunders
|
||||
assert stats.discarded_jokers == 0
|
||||
assert stats.discarded_twos == 0
|
||||
assert stats.took_bad_card_without_pair == 0
|
||||
assert stats.paired_negative_cards == 0
|
||||
|
||||
# Near-zero tolerance mistakes
|
||||
assert stats.discarded_kings < stats.total_turns * 0.001 # < 0.1%
|
||||
assert stats.swapped_good_for_bad < stats.total_turns * 0.001
|
||||
|
||||
# Acceptable variance
|
||||
assert stats.questionable_moves < stats.total_turns * 0.05 # < 5%
|
||||
```
|
||||
|
||||
### Tracking Implementation
|
||||
|
||||
Decision quality should be logged for analysis:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `decision_type` | take_discard, swap, discard, flip |
|
||||
| `decision_quality` | OPTIMAL, GOOD, QUESTIONABLE, MISTAKE, BLUNDER |
|
||||
| `expected_value` | EV calculation for the decision |
|
||||
| `profile_name` | AI personality that made decision |
|
||||
| `game_phase` | early, mid, late |
|
||||
|
||||
See `game_analyzer.py` for decision evaluation logic.
|
||||
|
||||
## Recommended Additional Tests
|
||||
|
||||
| Area | Description | Priority |
|
||||
|------|-------------|----------|
|
||||
| AI Quality Metrics | Simulation-based dumb move detection | **Critical** |
|
||||
| WebSocket | Integration tests for real-time communication | High |
|
||||
| Concurrent games | Multiple simultaneous rooms | Medium |
|
||||
| Deck exhaustion | Reshuffle when deck empty | Medium |
|
||||
| All house rule combos | Interaction between rules | Medium |
|
||||
| AI personality variance | Verify distinct behaviors | Low |
|
||||
| Performance | Load testing with many players | Low |
|
||||
|
||||
---
|
||||
|
||||
# Part 6: Strategic Notes
|
||||
|
||||
## Card Priority (for AI and Players)
|
||||
|
||||
### Always Keep
|
||||
- **Jokers** (-2 or -5): Best cards in game
|
||||
- **2s** (-2): Second best, but **don't pair them!**
|
||||
|
||||
### Keep When Possible
|
||||
- **Kings** (0): Safe, excellent for pairing
|
||||
- **Aces** (1): Low risk
|
||||
|
||||
### Bad Cards (replace these)
|
||||
- **10, J, Q** (10 points): Worst cards
|
||||
- **8, 9** (8-9 points): High priority to replace
|
||||
### Replace When Possible
|
||||
- **6, 7** (6-7 points): Moderate priority
|
||||
- **8, 9** (8-9 points): High priority
|
||||
- **10, J, Q** (10 points): Highest priority
|
||||
|
||||
## Pairing Strategy
|
||||
|
||||
### Pairing Strategy
|
||||
- Pairing is powerful - column score goes to 0
|
||||
- **Don't pair negative cards** - you lose the negative benefit
|
||||
- **Never pair negative cards** (2s, Jokers) - you lose the negative benefit
|
||||
- Target pairs with mid-value cards (3-7) for maximum gain
|
||||
- High-value pairs (10, J, Q) are valuable (+20 point swing)
|
||||
|
||||
### When to Go Out
|
||||
- Go out with **score ≤ 10** when confident you're lowest
|
||||
## When to Go Out
|
||||
|
||||
- Go out with **score <= 10** when confident you're lowest
|
||||
- Consider opponent visible cards before going out early
|
||||
- With `knock_penalty`, be careful - +10 hurts if you're wrong
|
||||
- With `knock_bonus`, more incentive to finish first
|
||||
|
||||
---
|
||||
|
||||
# Test Coverage
|
||||
# Part 7: Configuration
|
||||
|
||||
The game engine has comprehensive test coverage in `test_game.py`:
|
||||
## Configuration Files
|
||||
|
||||
- **Card Values:** All 13 ranks verified
|
||||
- **Column Pairing:** Matching, non-matching, negative card edge cases
|
||||
- **House Rules:** All scoring modifiers tested
|
||||
- **Draw/Discard:** Deck draws, discard draws, must-swap rule
|
||||
- **Turn Flow:** Turn advancement, wrap-around, player validation
|
||||
- **Round End:** Final turn triggering, one-more-turn logic
|
||||
- **Multi-Round:** Score accumulation, hand reset
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `pyproject.toml` | Project metadata, dependencies, tool config |
|
||||
| `server/config.py` | Centralized configuration loader |
|
||||
| `server/constants.py` | Card values and game constants |
|
||||
| `.env.example` | Environment variable documentation |
|
||||
| `.env` | Local environment overrides (not committed) |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Configuration precedence (highest to lowest):
|
||||
1. Environment variables
|
||||
2. `.env` file
|
||||
3. Default values in code
|
||||
|
||||
### Server Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `HOST` | `0.0.0.0` | Host to bind to |
|
||||
| `PORT` | `8000` | Port to listen on |
|
||||
| `DEBUG` | `false` | Enable debug mode |
|
||||
| `LOG_LEVEL` | `INFO` | Logging level |
|
||||
| `DATABASE_URL` | `sqlite:///games.db` | Database connection |
|
||||
|
||||
### Room Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MAX_PLAYERS_PER_ROOM` | `6` | Max players per game |
|
||||
| `ROOM_TIMEOUT_MINUTES` | `60` | Inactive room cleanup |
|
||||
| `ROOM_CODE_LENGTH` | `4` | Room code length |
|
||||
|
||||
### Game Defaults
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DEFAULT_ROUNDS` | `9` | Rounds per game |
|
||||
| `DEFAULT_INITIAL_FLIPS` | `2` | Cards to flip at start |
|
||||
| `DEFAULT_USE_JOKERS` | `false` | Enable jokers |
|
||||
| `DEFAULT_FLIP_ON_DISCARD` | `false` | Flip after discard |
|
||||
|
||||
### Security
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SECRET_KEY` | (empty) | Secret key for sessions |
|
||||
| `INVITE_ONLY` | `false` | Require invitation to register |
|
||||
| `ADMIN_EMAILS` | (empty) | Comma-separated admin emails |
|
||||
|
||||
## Running the Server
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
pytest test_game.py -v
|
||||
# Development (with auto-reload)
|
||||
DEBUG=true python server/main.py
|
||||
|
||||
# Production
|
||||
PORT=8080 LOG_LEVEL=WARNING python server/main.py
|
||||
|
||||
# With .env file
|
||||
cp .env.example .env
|
||||
# Edit .env as needed
|
||||
python server/main.py
|
||||
|
||||
# Using uvicorn directly
|
||||
uvicorn server.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Part 8: Authentication
|
||||
|
||||
## Overview
|
||||
|
||||
The authentication system supports:
|
||||
- User accounts stored in SQLite (`users` table)
|
||||
- Admin accounts that can manage other users
|
||||
- Invite codes (or room codes) for registration
|
||||
- Session-based authentication with bearer tokens
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
When the server starts with no admin accounts:
|
||||
1. A default `admin` account is created (or accounts for each email in `ADMIN_EMAILS`)
|
||||
2. The admin account has **no password** initially
|
||||
3. On first login attempt, use `/api/auth/setup-password` to set the password
|
||||
|
||||
```bash
|
||||
# Check if admin needs setup
|
||||
curl http://localhost:8000/api/auth/check-setup/admin
|
||||
|
||||
# Set admin password (first time only)
|
||||
curl -X POST http://localhost:8000/api/auth/setup-password \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "admin", "new_password": "your-secure-password"}'
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Public Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/auth/check-setup/{username}` | GET | Check if user needs password setup |
|
||||
| `/api/auth/setup-password` | POST | Set password (first login only) |
|
||||
| `/api/auth/login` | POST | Login with username/password |
|
||||
| `/api/auth/register` | POST | Register with invite code |
|
||||
| `/api/auth/logout` | POST | Logout current session |
|
||||
|
||||
### Authenticated Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/auth/me` | GET | Get current user info |
|
||||
| `/api/auth/password` | PUT | Change own password |
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/admin/users` | GET | List all users |
|
||||
| `/api/admin/users/{id}` | GET | Get user by ID |
|
||||
| `/api/admin/users/{id}` | PUT | Update user |
|
||||
| `/api/admin/users/{id}/password` | PUT | Change user password |
|
||||
| `/api/admin/users/{id}` | DELETE | Deactivate user |
|
||||
| `/api/admin/invites` | POST | Create invite code |
|
||||
| `/api/admin/invites` | GET | List invite codes |
|
||||
| `/api/admin/invites/{code}` | DELETE | Deactivate invite code |
|
||||
|
||||
## Registration Flow
|
||||
|
||||
1. User obtains an invite code (from admin) or a room code (from active game)
|
||||
2. User calls `/api/auth/register` with username, password, and invite code
|
||||
3. If valid, account is created and session token is returned
|
||||
|
||||
```bash
|
||||
# Register with room code
|
||||
curl -X POST http://localhost:8000/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "player1", "password": "pass123", "invite_code": "ABCD"}'
|
||||
```
|
||||
|
||||
## Authentication Header
|
||||
|
||||
After login, include the token in requests:
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
-- Users table
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE,
|
||||
password_hash TEXT NOT NULL, -- SHA-256 with salt
|
||||
role TEXT DEFAULT 'user', -- 'user' or 'admin'
|
||||
created_at TIMESTAMP,
|
||||
last_login TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
invited_by TEXT
|
||||
);
|
||||
|
||||
-- Sessions table
|
||||
CREATE TABLE sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id),
|
||||
created_at TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
-- Invite codes table
|
||||
CREATE TABLE invite_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
created_by TEXT REFERENCES users(id),
|
||||
created_at TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
max_uses INTEGER DEFAULT 1,
|
||||
use_count INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
);
|
||||
```
|
||||
|
||||
| Implementation | File |
|
||||
|----------------|------|
|
||||
| AuthManager class | `auth.py:87-460` |
|
||||
| User model | `auth.py:27-50` |
|
||||
| Password hashing | `auth.py:159-172` |
|
||||
| Session management | `auth.py:316-360` |
|
||||
|
||||
| Tests | File |
|
||||
|-------|------|
|
||||
| User creation | `test_auth.py:22-60` |
|
||||
| Authentication | `test_auth.py:63-120` |
|
||||
| Invite codes | `test_auth.py:123-175` |
|
||||
| Admin functions | `test_auth.py:178-220` |
|
||||
|
||||
---
|
||||
|
||||
*Last updated: Document generated from codebase analysis*
|
||||
*Reference implementations: config.py, constants.py, game.py, ai.py, auth.py*
|
||||
*Test suites: test_game.py, test_house_rules.py, test_analyzer.py, test_maya_bug.py, test_auth.py*
|
||||
|
||||
Reference in New Issue
Block a user