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*
|
||||
|
||||
449
server/ai.py
449
server/ai.py
@@ -1,6 +1,7 @@
|
||||
"""AI personalities for CPU players in Golf."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
@@ -9,6 +10,29 @@ from enum import Enum
|
||||
from game import Card, Player, Game, GamePhase, GameOptions, RANK_VALUES, Rank, get_card_value
|
||||
|
||||
|
||||
# Debug logging configuration
|
||||
# Set AI_DEBUG=1 environment variable to enable detailed AI decision logging
|
||||
AI_DEBUG = os.environ.get("AI_DEBUG", "0") == "1"
|
||||
|
||||
# Create a dedicated logger for AI decisions
|
||||
ai_logger = logging.getLogger("golf.ai")
|
||||
if AI_DEBUG:
|
||||
ai_logger.setLevel(logging.DEBUG)
|
||||
# Add console handler if not already present
|
||||
if not ai_logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter(
|
||||
"%(asctime)s [AI] %(message)s", datefmt="%H:%M:%S"
|
||||
))
|
||||
ai_logger.addHandler(handler)
|
||||
|
||||
|
||||
def ai_log(message: str):
|
||||
"""Log AI decision info when AI_DEBUG is enabled."""
|
||||
if AI_DEBUG:
|
||||
ai_logger.debug(message)
|
||||
|
||||
|
||||
# Alias for backwards compatibility - use the centralized function from game.py
|
||||
def get_ai_card_value(card: Card, options: GameOptions) -> int:
|
||||
"""Get card value with house rules applied for AI decisions.
|
||||
@@ -161,6 +185,62 @@ def has_worse_visible_card(player: Player, card_value: int, options: GameOptions
|
||||
return False
|
||||
|
||||
|
||||
def get_column_partner_position(pos: int) -> int:
|
||||
"""Get the column partner position for a given position.
|
||||
|
||||
Column pairs: (0,3), (1,4), (2,5)
|
||||
"""
|
||||
return (pos + 3) % 6 if pos < 3 else pos - 3
|
||||
|
||||
|
||||
def filter_bad_pair_positions(
|
||||
positions: list[int],
|
||||
drawn_card: Card,
|
||||
player: Player,
|
||||
options: GameOptions
|
||||
) -> list[int]:
|
||||
"""Filter out positions that would create wasteful pairs with negative cards.
|
||||
|
||||
When placing a card (especially negative value cards like 2s or Jokers),
|
||||
we should avoid positions where the column partner is a visible card of
|
||||
the same rank - pairing negative cards wastes their value.
|
||||
|
||||
Args:
|
||||
positions: List of candidate positions
|
||||
drawn_card: The card we're placing
|
||||
player: The player's hand
|
||||
options: Game options for house rules
|
||||
|
||||
Returns:
|
||||
Filtered list excluding bad pair positions. If all positions are bad,
|
||||
returns the original list (we have to place somewhere).
|
||||
"""
|
||||
drawn_value = get_ai_card_value(drawn_card, options)
|
||||
|
||||
# Only filter if the drawn card has negative value (2s, Jokers, super_kings Kings)
|
||||
# Pairing positive cards is fine - it turns their value to 0
|
||||
if drawn_value >= 0:
|
||||
return positions
|
||||
|
||||
# Exception: Eagle Eye makes pairing Jokers GOOD (-4 instead of 0)
|
||||
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
||||
return positions
|
||||
|
||||
filtered = []
|
||||
for pos in positions:
|
||||
partner_pos = get_column_partner_position(pos)
|
||||
partner = player.cards[partner_pos]
|
||||
|
||||
# If partner is face-up and same rank, this would create a wasteful pair
|
||||
if partner.face_up and partner.rank == drawn_card.rank:
|
||||
continue # Skip this position
|
||||
|
||||
filtered.append(pos)
|
||||
|
||||
# If all positions were filtered out, return original (must place somewhere)
|
||||
return filtered if filtered else positions
|
||||
|
||||
|
||||
@dataclass
|
||||
class CPUProfile:
|
||||
"""Pre-defined CPU player profile with personality traits."""
|
||||
@@ -340,6 +420,40 @@ class GolfAI:
|
||||
options = game.options
|
||||
discard_value = get_ai_card_value(discard_card, options)
|
||||
|
||||
ai_log(f"--- {profile.name} considering discard: {discard_card.rank.value}{discard_card.suit.value} (value={discard_value}) ---")
|
||||
|
||||
# SAFEGUARD: If we have only 1 face-down card, taking from discard
|
||||
# forces us to swap and go out. Check if that would be acceptable.
|
||||
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||
if len(face_down) == 1:
|
||||
# Calculate projected score if we swap into the last face-down position
|
||||
projected_score = 0
|
||||
for i, c in enumerate(player.cards):
|
||||
if i == face_down[0]:
|
||||
projected_score += discard_value
|
||||
elif c.face_up:
|
||||
projected_score += get_ai_card_value(c, options)
|
||||
|
||||
# Apply column pair cancellation
|
||||
for col in range(3):
|
||||
top_idx, bot_idx = col, col + 3
|
||||
top_card = discard_card if top_idx == face_down[0] else player.cards[top_idx]
|
||||
bot_card = discard_card if bot_idx == face_down[0] else player.cards[bot_idx]
|
||||
if top_card.rank == bot_card.rank:
|
||||
top_val = discard_value if top_idx == face_down[0] else get_ai_card_value(player.cards[top_idx], options)
|
||||
bot_val = discard_value if bot_idx == face_down[0] else get_ai_card_value(player.cards[bot_idx], options)
|
||||
projected_score -= (top_val + bot_val)
|
||||
|
||||
# Don't take if score would be terrible
|
||||
max_acceptable = 18 if profile.aggression > 0.6 else (16 if profile.aggression > 0.3 else 14)
|
||||
ai_log(f" Go-out check: projected={projected_score}, max_acceptable={max_acceptable}")
|
||||
if projected_score > max_acceptable:
|
||||
# Exception: still take if it's an excellent card (Joker, 2, King, Ace)
|
||||
# and we have a visible bad card to replace instead
|
||||
if discard_value >= 0 and discard_card.rank not in (Rank.ACE, Rank.TWO, Rank.KING, Rank.JOKER):
|
||||
ai_log(f" >> REJECT: would force go-out with {projected_score} pts")
|
||||
return False # Don't take - would force bad go-out
|
||||
|
||||
# Unpredictable players occasionally make random choice
|
||||
# BUT only for reasonable cards (value <= 5) - never randomly take bad cards
|
||||
if random.random() < profile.unpredictability:
|
||||
@@ -352,14 +466,18 @@ class GolfAI:
|
||||
if options.eagle_eye:
|
||||
for card in player.cards:
|
||||
if card.face_up and card.rank == Rank.JOKER:
|
||||
ai_log(f" >> TAKE: Joker for Eagle Eye pair")
|
||||
return True
|
||||
ai_log(f" >> TAKE: Joker (always take)")
|
||||
return True
|
||||
|
||||
if discard_card.rank == Rank.KING:
|
||||
ai_log(f" >> TAKE: King (always take)")
|
||||
return True
|
||||
|
||||
# Auto-take 10s when ten_penny enabled (they're worth 1)
|
||||
if discard_card.rank == Rank.TEN and options.ten_penny:
|
||||
ai_log(f" >> TAKE: 10 (ten_penny rule)")
|
||||
return True
|
||||
|
||||
# Take card if it could make a column pair (but NOT for negative value cards)
|
||||
@@ -371,6 +489,7 @@ class GolfAI:
|
||||
|
||||
# Direct rank match
|
||||
if card.face_up and card.rank == discard_card.rank and not pair_card.face_up:
|
||||
ai_log(f" >> TAKE: can pair with visible {card.rank.value} at pos {i}")
|
||||
return True
|
||||
|
||||
# Take low cards (using house rule adjusted values)
|
||||
@@ -379,6 +498,7 @@ class GolfAI:
|
||||
base_threshold = {'early': 2, 'mid': 3, 'late': 4}.get(phase, 2)
|
||||
|
||||
if discard_value <= base_threshold:
|
||||
ai_log(f" >> TAKE: low card (value {discard_value} <= {base_threshold} threshold for {phase} game)")
|
||||
return True
|
||||
|
||||
# Calculate end-game pressure from opponents close to going out
|
||||
@@ -395,6 +515,7 @@ class GolfAI:
|
||||
# Only take if we have hidden cards that could be worse
|
||||
my_hidden = sum(1 for c in player.cards if not c.face_up)
|
||||
if my_hidden > 0:
|
||||
ai_log(f" >> TAKE: pressure={pressure:.2f}, threshold={pressure_threshold}")
|
||||
return True
|
||||
|
||||
# Check if we have cards worse than the discard
|
||||
@@ -407,133 +528,281 @@ class GolfAI:
|
||||
# Sanity check: only take if we actually have something worse to replace
|
||||
# This prevents taking a bad card when all visible cards are better
|
||||
if has_worse_visible_card(player, discard_value, options):
|
||||
ai_log(f" >> TAKE: have worse visible card ({worst_visible})")
|
||||
return True
|
||||
|
||||
ai_log(f" >> PASS: drawing from deck instead")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def calculate_swap_score(
|
||||
pos: int,
|
||||
drawn_card: Card,
|
||||
drawn_value: int,
|
||||
player: Player,
|
||||
options: GameOptions,
|
||||
game: Game,
|
||||
profile: CPUProfile
|
||||
) -> float:
|
||||
"""
|
||||
Calculate a score for swapping into a specific position.
|
||||
Higher score = better swap. Weighs all incentives:
|
||||
- Pair bonus (highest priority for positive cards)
|
||||
- Point gain from replacement
|
||||
- Reveal bonus for hidden cards
|
||||
- Go-out safety check
|
||||
|
||||
Personality traits affect weights:
|
||||
- pair_hope: higher = values pairing more, lower = prefers spreading
|
||||
- aggression: higher = more willing to go out, take risks
|
||||
- swap_threshold: affects how picky about card values
|
||||
"""
|
||||
current_card = player.cards[pos]
|
||||
partner_pos = get_column_partner_position(pos)
|
||||
partner_card = player.cards[partner_pos]
|
||||
|
||||
score = 0.0
|
||||
|
||||
# Personality-based weight modifiers
|
||||
# pair_hope: 0.0-1.0, affects how much we value pairing vs spreading
|
||||
pair_weight = 1.0 + profile.pair_hope # Range: 1.0 to 2.0
|
||||
spread_weight = 2.0 - profile.pair_hope # Range: 1.0 to 2.0 (inverse)
|
||||
|
||||
# 1. PAIR BONUS - Creating a pair
|
||||
# pair_hope affects how much we value this
|
||||
if partner_card.face_up and partner_card.rank == drawn_card.rank:
|
||||
partner_value = get_ai_card_value(partner_card, options)
|
||||
|
||||
if drawn_value >= 0:
|
||||
# Good pair! Both cards cancel to 0
|
||||
pair_bonus = drawn_value + partner_value
|
||||
score += pair_bonus * pair_weight # Pair hunters value this more
|
||||
else:
|
||||
# Pairing negative cards - usually bad
|
||||
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
||||
score += 8 * pair_weight # Eagle Eye Joker pairs
|
||||
else:
|
||||
# Penalty, but pair hunters might still do it
|
||||
penalty = abs(drawn_value) * 2 * (2.0 - profile.pair_hope)
|
||||
score -= penalty
|
||||
|
||||
# 1b. SPREAD BONUS - Not pairing good cards (spreading them out)
|
||||
# Players with low pair_hope prefer spreading aces/2s across columns
|
||||
if not partner_card.face_up or partner_card.rank != drawn_card.rank:
|
||||
if drawn_value <= 1: # Excellent cards (K, 2, A, Joker)
|
||||
# Small bonus for spreading - scales with spread preference
|
||||
score += spread_weight * 0.5
|
||||
|
||||
# 2. POINT GAIN - Direct value improvement
|
||||
if current_card.face_up:
|
||||
current_value = get_ai_card_value(current_card, options)
|
||||
point_gain = current_value - drawn_value
|
||||
score += point_gain
|
||||
else:
|
||||
# Hidden card - expected value ~4.5
|
||||
expected_hidden = 4.5
|
||||
point_gain = expected_hidden - drawn_value
|
||||
# Conservative players (low swap_threshold) discount uncertain gains more
|
||||
discount = 0.5 + (profile.swap_threshold / 16) # Range: 0.5 to 1.0
|
||||
score += point_gain * discount
|
||||
|
||||
# 3. REVEAL BONUS - Value of revealing hidden cards
|
||||
# More aggressive players want to reveal faster to go out
|
||||
if not current_card.face_up:
|
||||
hidden_count = sum(1 for c in player.cards if not c.face_up)
|
||||
reveal_bonus = min(hidden_count, 4)
|
||||
|
||||
# Aggressive players get bigger reveal bonus (want to go out faster)
|
||||
aggression_multiplier = 0.8 + profile.aggression * 0.4 # Range: 0.8 to 1.2
|
||||
|
||||
# Scale by card quality
|
||||
if drawn_value <= 0: # Excellent
|
||||
score += reveal_bonus * 1.2 * aggression_multiplier
|
||||
elif drawn_value == 1: # Great
|
||||
score += reveal_bonus * 1.0 * aggression_multiplier
|
||||
elif drawn_value <= 4: # Good
|
||||
score += reveal_bonus * 0.6 * aggression_multiplier
|
||||
elif drawn_value <= 6: # Medium
|
||||
score += reveal_bonus * 0.3 * aggression_multiplier
|
||||
# Bad cards: no reveal bonus
|
||||
|
||||
# 4. FUTURE PAIR POTENTIAL
|
||||
# Pair hunters value positions where both cards are hidden
|
||||
if not current_card.face_up and not partner_card.face_up:
|
||||
pair_viability = get_pair_viability(drawn_card.rank, game)
|
||||
score += pair_viability * pair_weight * 0.5
|
||||
|
||||
# 5. GO-OUT SAFETY - Penalty for going out with bad score
|
||||
face_down_positions = [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||
if len(face_down_positions) == 1 and pos == face_down_positions[0]:
|
||||
projected_score = drawn_value
|
||||
for i, c in enumerate(player.cards):
|
||||
if i != pos and c.face_up:
|
||||
projected_score += get_ai_card_value(c, options)
|
||||
|
||||
# Apply pair cancellation
|
||||
for col in range(3):
|
||||
top_idx, bot_idx = col, col + 3
|
||||
top_card = drawn_card if top_idx == pos else player.cards[top_idx]
|
||||
bot_card = drawn_card if bot_idx == pos else player.cards[bot_idx]
|
||||
if top_card.rank == bot_card.rank:
|
||||
top_val = drawn_value if top_idx == pos else get_ai_card_value(player.cards[top_idx], options)
|
||||
bot_val = drawn_value if bot_idx == pos else get_ai_card_value(player.cards[bot_idx], options)
|
||||
projected_score -= (top_val + bot_val)
|
||||
|
||||
# Aggressive players accept higher scores when going out
|
||||
max_acceptable = 12 + int(profile.aggression * 8) # Range: 12 to 20
|
||||
if projected_score > max_acceptable:
|
||||
score -= 100
|
||||
|
||||
return score
|
||||
|
||||
@staticmethod
|
||||
def choose_swap_or_discard(drawn_card: Card, player: Player,
|
||||
profile: CPUProfile, game: Game) -> Optional[int]:
|
||||
"""
|
||||
Decide whether to swap the drawn card or discard.
|
||||
Returns position to swap with, or None to discard.
|
||||
|
||||
Uses a unified scoring system that weighs all incentives:
|
||||
- Pair creation (best for positive cards, bad for negative)
|
||||
- Point gain from replacement
|
||||
- Revealing hidden cards (catching up, information)
|
||||
- Safety (don't go out with terrible score)
|
||||
"""
|
||||
options = game.options
|
||||
drawn_value = get_ai_card_value(drawn_card, options)
|
||||
|
||||
# Unpredictable players occasionally make surprising play
|
||||
# BUT never discard excellent cards (Jokers, 2s, Kings, Aces)
|
||||
ai_log(f"=== {profile.name} deciding: drew {drawn_card.rank.value}{drawn_card.suit.value} (value={drawn_value}) ===")
|
||||
ai_log(f" Personality: pair_hope={profile.pair_hope:.2f}, aggression={profile.aggression:.2f}, "
|
||||
f"swap_threshold={profile.swap_threshold}, unpredictability={profile.unpredictability:.2f}")
|
||||
|
||||
# Log current hand state
|
||||
hand_str = " ".join(
|
||||
f"[{i}:{c.rank.value if c.face_up else '?'}]" for i, c in enumerate(player.cards)
|
||||
)
|
||||
ai_log(f" Hand: {hand_str}")
|
||||
|
||||
# Unpredictable players occasionally make surprising plays
|
||||
# But never discard excellent cards (Jokers, 2s, Kings, Aces)
|
||||
if random.random() < profile.unpredictability:
|
||||
if drawn_value > 1: # Only be unpredictable with non-excellent cards
|
||||
if drawn_value > 1:
|
||||
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||
if face_down and random.random() < 0.5:
|
||||
return random.choice(face_down)
|
||||
choice = random.choice(face_down)
|
||||
ai_log(f" >> UNPREDICTABLE: randomly chose position {choice}")
|
||||
return choice
|
||||
|
||||
# Eagle Eye: If drawn card is Joker, look for existing visible Joker to pair
|
||||
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
||||
for i, card in enumerate(player.cards):
|
||||
if card.face_up and card.rank == Rank.JOKER:
|
||||
pair_pos = (i + 3) % 6 if i < 3 else i - 3
|
||||
if not player.cards[pair_pos].face_up:
|
||||
return pair_pos
|
||||
# Calculate score for each position
|
||||
position_scores: list[tuple[int, float]] = []
|
||||
for pos in range(6):
|
||||
score = GolfAI.calculate_swap_score(
|
||||
pos, drawn_card, drawn_value, player, options, game, profile
|
||||
)
|
||||
position_scores.append((pos, score))
|
||||
|
||||
# Check for column pair opportunity first
|
||||
# But DON'T pair negative value cards (2s, Jokers) - keeping them unpaired is better!
|
||||
# Exception: Eagle Eye makes pairing Jokers GOOD (doubled negative)
|
||||
should_pair = drawn_value > 0
|
||||
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
||||
should_pair = True
|
||||
# Log all scores
|
||||
ai_log(f" Position scores:")
|
||||
for pos, score in position_scores:
|
||||
card = player.cards[pos]
|
||||
partner_pos = get_column_partner_position(pos)
|
||||
partner = player.cards[partner_pos]
|
||||
card_str = card.rank.value if card.face_up else "?"
|
||||
partner_str = partner.rank.value if partner.face_up else "?"
|
||||
pair_indicator = " [PAIR]" if partner.face_up and partner.rank == drawn_card.rank else ""
|
||||
reveal_indicator = " [REVEAL]" if not card.face_up else ""
|
||||
ai_log(f" pos {pos} ({card_str}, partner={partner_str}): {score:+.2f}{pair_indicator}{reveal_indicator}")
|
||||
|
||||
if should_pair:
|
||||
for i, card in enumerate(player.cards):
|
||||
pair_pos = (i + 3) % 6 if i < 3 else i - 3
|
||||
pair_card = player.cards[pair_pos]
|
||||
# Filter to positive scores only
|
||||
positive_scores = [(p, s) for p, s in position_scores if s > 0]
|
||||
|
||||
# Direct rank match
|
||||
if card.face_up and card.rank == drawn_card.rank and not pair_card.face_up:
|
||||
return pair_pos
|
||||
best_pos: Optional[int] = None
|
||||
best_score = 0.0
|
||||
|
||||
if pair_card.face_up and pair_card.rank == drawn_card.rank and not card.face_up:
|
||||
return i
|
||||
if positive_scores:
|
||||
# Sort by score descending
|
||||
positive_scores.sort(key=lambda x: x[1], reverse=True)
|
||||
best_pos, best_score = positive_scores[0]
|
||||
|
||||
# Find best swap among face-up cards that are BAD (positive value)
|
||||
# Don't swap good cards (Kings, 2s, etc.) just for marginal gains -
|
||||
# we want to keep good cards and put new good cards into face-down positions
|
||||
best_swap: Optional[int] = None
|
||||
best_gain = 0
|
||||
# PERSONALITY TIE-BREAKER: When top options are close, let personality decide
|
||||
close_threshold = 2.0 # Options within 2 points are "close"
|
||||
close_options = [(p, s) for p, s in positive_scores if s >= best_score - close_threshold]
|
||||
|
||||
for i, card in enumerate(player.cards):
|
||||
if card.face_up:
|
||||
card_value = get_ai_card_value(card, options)
|
||||
# Only consider replacing cards that are actually bad (positive value)
|
||||
if card_value > 0:
|
||||
gain = card_value - drawn_value
|
||||
if gain > best_gain:
|
||||
best_gain = gain
|
||||
best_swap = i
|
||||
if len(close_options) > 1:
|
||||
ai_log(f" TIE-BREAKER: {len(close_options)} options within {close_threshold} pts of best ({best_score:.2f})")
|
||||
original_best = best_pos
|
||||
|
||||
# Swap if we gain points (conservative players need more gain)
|
||||
min_gain = 2 if profile.swap_threshold <= 4 else 1
|
||||
if best_gain >= min_gain:
|
||||
return best_swap
|
||||
# Multiple close options - personality decides
|
||||
# Categorize each option
|
||||
for pos, score in close_options:
|
||||
partner_pos = get_column_partner_position(pos)
|
||||
partner_card = player.cards[partner_pos]
|
||||
is_pair_move = partner_card.face_up and partner_card.rank == drawn_card.rank
|
||||
is_reveal_move = not player.cards[pos].face_up
|
||||
|
||||
# Blackjack: Check if any swap would result in exactly 21
|
||||
if options.blackjack:
|
||||
# Pair hunters prefer pair moves
|
||||
if is_pair_move and profile.pair_hope > 0.6:
|
||||
ai_log(f" >> PAIR_HOPE ({profile.pair_hope:.2f}): chose pair move at pos {pos}")
|
||||
best_pos = pos
|
||||
break
|
||||
# Aggressive players prefer reveal moves (to go out faster)
|
||||
if is_reveal_move and profile.aggression > 0.7:
|
||||
ai_log(f" >> AGGRESSION ({profile.aggression:.2f}): chose reveal move at pos {pos}")
|
||||
best_pos = pos
|
||||
break
|
||||
# Conservative players prefer safe visible card replacements
|
||||
if not is_reveal_move and profile.swap_threshold <= 4:
|
||||
ai_log(f" >> CONSERVATIVE (threshold={profile.swap_threshold}): chose safe move at pos {pos}")
|
||||
best_pos = pos
|
||||
break
|
||||
|
||||
# If still tied, add small random factor based on unpredictability
|
||||
if profile.unpredictability > 0.1 and random.random() < profile.unpredictability:
|
||||
best_pos = random.choice([p for p, s in close_options])
|
||||
ai_log(f" >> RANDOM (unpredictability={profile.unpredictability:.2f}): chose pos {best_pos}")
|
||||
|
||||
if best_pos != original_best:
|
||||
ai_log(f" Tie-breaker changed choice: {original_best} -> {best_pos}")
|
||||
|
||||
# Blackjack special case: chase exactly 21
|
||||
if options.blackjack and best_pos is None:
|
||||
current_score = player.calculate_score()
|
||||
if current_score >= 15: # Only chase 21 from high scores
|
||||
if current_score >= 15:
|
||||
for i, card in enumerate(player.cards):
|
||||
if card.face_up:
|
||||
# Calculate score if we swap here
|
||||
potential_change = drawn_value - get_ai_card_value(card, options)
|
||||
potential_score = current_score + potential_change
|
||||
if potential_score == 21:
|
||||
# Aggressive players more likely to chase 21
|
||||
if current_score + potential_change == 21:
|
||||
if random.random() < profile.aggression:
|
||||
ai_log(f" >> BLACKJACK: chasing 21 at position {i}")
|
||||
return i
|
||||
|
||||
# Consider swapping with face-down cards for very good cards (negative or zero value)
|
||||
# 10s (ten_penny) become "excellent" cards worth keeping
|
||||
is_excellent = (drawn_value <= 0 or
|
||||
drawn_card.rank == Rank.ACE or
|
||||
(options.ten_penny and drawn_card.rank == Rank.TEN))
|
||||
# Pair hunters might hold medium cards hoping for matches
|
||||
if best_pos is not None and not player.cards[best_pos].face_up:
|
||||
if drawn_value >= 5: # Only hold out for medium/high cards
|
||||
pair_viability = get_pair_viability(drawn_card.rank, game)
|
||||
phase = get_game_phase(game)
|
||||
pressure = get_end_game_pressure(player, game)
|
||||
|
||||
# Calculate pair viability and game phase for smarter decisions
|
||||
pair_viability = get_pair_viability(drawn_card.rank, game)
|
||||
phase = get_game_phase(game)
|
||||
pressure = get_end_game_pressure(player, game)
|
||||
|
||||
if is_excellent:
|
||||
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||
if face_down:
|
||||
# Pair hunters might hold out hoping for matches
|
||||
# BUT: reduce hope if pair is unlikely or late game pressure
|
||||
effective_hope = profile.pair_hope * pair_viability
|
||||
if phase == 'late' or pressure > 0.5:
|
||||
effective_hope *= 0.3 # Much less willing to gamble late game
|
||||
if effective_hope > 0.6 and random.random() < effective_hope:
|
||||
return None
|
||||
return random.choice(face_down)
|
||||
effective_hope *= 0.3
|
||||
|
||||
# For medium cards, swap threshold based on profile
|
||||
# Late game: be more willing to swap in medium cards
|
||||
effective_threshold = profile.swap_threshold
|
||||
if phase == 'late' or pressure > 0.5:
|
||||
effective_threshold += 2 # Accept higher value cards under pressure
|
||||
ai_log(f" Hold-for-pair check: value={drawn_value}, viability={pair_viability:.2f}, "
|
||||
f"phase={phase}, effective_hope={effective_hope:.2f}")
|
||||
|
||||
if drawn_value <= effective_threshold:
|
||||
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||
if face_down:
|
||||
# Pair hunters hold high cards hoping for matches
|
||||
# BUT: check if pairing is actually viable
|
||||
effective_hope = profile.pair_hope * pair_viability
|
||||
if phase == 'late' or pressure > 0.5:
|
||||
effective_hope *= 0.3 # Don't gamble late game
|
||||
if effective_hope > 0.5 and drawn_value >= 6:
|
||||
if random.random() < effective_hope:
|
||||
return None
|
||||
return random.choice(face_down)
|
||||
if effective_hope > 0.5 and random.random() < effective_hope:
|
||||
ai_log(f" >> HOLDING: discarding {drawn_card.rank.value} hoping for future pair")
|
||||
return None # Discard and hope for pair later
|
||||
|
||||
return None
|
||||
# Log final decision
|
||||
if best_pos is not None:
|
||||
target_card = player.cards[best_pos]
|
||||
target_str = target_card.rank.value if target_card.face_up else "hidden"
|
||||
ai_log(f" DECISION: SWAP into position {best_pos} (replacing {target_str}) [score={best_score:.2f}]")
|
||||
else:
|
||||
ai_log(f" DECISION: DISCARD {drawn_card.rank.value} (no good swap options)")
|
||||
|
||||
return best_pos
|
||||
|
||||
@staticmethod
|
||||
def choose_flip_after_discard(player: Player, profile: CPUProfile) -> int:
|
||||
@@ -665,7 +934,9 @@ async def process_cpu_turn(
|
||||
if swap_pos is None and game.drawn_from_discard:
|
||||
face_down = [i for i, c in enumerate(cpu_player.cards) if not c.face_up]
|
||||
if face_down:
|
||||
swap_pos = random.choice(face_down)
|
||||
# Filter out positions that would create bad pairs with negative cards
|
||||
safe_positions = filter_bad_pair_positions(face_down, drawn, cpu_player, game.options)
|
||||
swap_pos = random.choice(safe_positions)
|
||||
else:
|
||||
# All cards are face up - find worst card to replace (using house rules)
|
||||
worst_pos = 0
|
||||
|
||||
602
server/auth.py
Normal file
602
server/auth.py
Normal file
@@ -0,0 +1,602 @@
|
||||
"""
|
||||
Authentication and user management for Golf game.
|
||||
|
||||
Features:
|
||||
- User accounts stored in SQLite
|
||||
- Admin accounts can manage other users
|
||||
- Invite codes (room codes) allow new user registration
|
||||
- Session-based authentication via tokens
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from config import config
|
||||
|
||||
|
||||
class UserRole(Enum):
|
||||
"""User roles for access control."""
|
||||
USER = "user"
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""User account."""
|
||||
id: str
|
||||
username: str
|
||||
email: Optional[str]
|
||||
password_hash: str
|
||||
role: UserRole
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime]
|
||||
is_active: bool
|
||||
invited_by: Optional[str] # Username of who invited them
|
||||
|
||||
def is_admin(self) -> bool:
|
||||
return self.role == UserRole.ADMIN
|
||||
|
||||
def to_dict(self, include_sensitive: bool = False) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
data = {
|
||||
"id": self.id,
|
||||
"username": self.username,
|
||||
"email": self.email,
|
||||
"role": self.role.value,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"last_login": self.last_login.isoformat() if self.last_login else None,
|
||||
"is_active": self.is_active,
|
||||
"invited_by": self.invited_by,
|
||||
}
|
||||
if include_sensitive:
|
||||
data["password_hash"] = self.password_hash
|
||||
return data
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""User session."""
|
||||
token: str
|
||||
user_id: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
return datetime.now() > self.expires_at
|
||||
|
||||
|
||||
@dataclass
|
||||
class InviteCode:
|
||||
"""Invite code for user registration."""
|
||||
code: str
|
||||
created_by: str # User ID who created the invite
|
||||
created_at: datetime
|
||||
expires_at: Optional[datetime]
|
||||
max_uses: int
|
||||
use_count: int
|
||||
is_active: bool
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
if not self.is_active:
|
||||
return False
|
||||
if self.expires_at and datetime.now() > self.expires_at:
|
||||
return False
|
||||
if self.max_uses > 0 and self.use_count >= self.max_uses:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""Manages user authentication and authorization."""
|
||||
|
||||
def __init__(self, db_path: str = "games.db"):
|
||||
self.db_path = Path(db_path)
|
||||
self._init_db()
|
||||
self._ensure_admin()
|
||||
|
||||
def _init_db(self):
|
||||
"""Initialize auth database schema."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.executescript("""
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
invited_by TEXT
|
||||
);
|
||||
|
||||
-- Sessions table
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
-- Invite codes table
|
||||
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
created_by TEXT REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
max_uses INTEGER DEFAULT 1,
|
||||
use_count INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_codes_active ON invite_codes(is_active);
|
||||
""")
|
||||
|
||||
def _ensure_admin(self):
|
||||
"""Ensure at least one admin account exists (without password - must be set on first login)."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE role = ?",
|
||||
(UserRole.ADMIN.value,)
|
||||
)
|
||||
admin_count = cursor.fetchone()[0]
|
||||
|
||||
if admin_count == 0:
|
||||
# Check if admin emails are configured
|
||||
if config.ADMIN_EMAILS:
|
||||
# Create admin accounts for configured emails (no password yet)
|
||||
for email in config.ADMIN_EMAILS:
|
||||
username = email.split("@")[0]
|
||||
self._create_user_without_password(
|
||||
username=username,
|
||||
email=email,
|
||||
role=UserRole.ADMIN,
|
||||
)
|
||||
print(f"Created admin account: {username} - password must be set on first login")
|
||||
else:
|
||||
# Create default admin if no admins exist (no password yet)
|
||||
self._create_user_without_password(
|
||||
username="admin",
|
||||
role=UserRole.ADMIN,
|
||||
)
|
||||
print("Created default admin account - password must be set on first login")
|
||||
print("Set ADMIN_EMAILS in .env to configure admin accounts.")
|
||||
|
||||
def _create_user_without_password(
|
||||
self,
|
||||
username: str,
|
||||
email: Optional[str] = None,
|
||||
role: UserRole = UserRole.USER,
|
||||
) -> Optional[str]:
|
||||
"""Create a user without a password (for first-time setup)."""
|
||||
user_id = secrets.token_hex(16)
|
||||
# Empty password_hash indicates password needs to be set
|
||||
password_hash = ""
|
||||
|
||||
try:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO users (id, username, email, password_hash, role)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(user_id, username, email, password_hash, role.value),
|
||||
)
|
||||
return user_id
|
||||
except sqlite3.IntegrityError:
|
||||
return None
|
||||
|
||||
def needs_password_setup(self, username: str) -> bool:
|
||||
"""Check if user needs to set up their password (first login)."""
|
||||
user = self.get_user_by_username(username)
|
||||
if not user:
|
||||
return False
|
||||
return user.password_hash == ""
|
||||
|
||||
def setup_password(self, username: str, new_password: str) -> Optional[User]:
|
||||
"""Set password for first-time setup. Only works if password is not yet set."""
|
||||
user = self.get_user_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
if user.password_hash != "":
|
||||
return None # Password already set
|
||||
|
||||
password_hash = self._hash_password(new_password)
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash = ?, last_login = ? WHERE id = ?",
|
||||
(password_hash, datetime.now(), user.id)
|
||||
)
|
||||
|
||||
return self.get_user_by_id(user.id)
|
||||
|
||||
@staticmethod
|
||||
def _hash_password(password: str) -> str:
|
||||
"""Hash a password using SHA-256 with salt."""
|
||||
salt = secrets.token_hex(16)
|
||||
hash_input = f"{salt}:{password}".encode()
|
||||
password_hash = hashlib.sha256(hash_input).hexdigest()
|
||||
return f"{salt}:{password_hash}"
|
||||
|
||||
@staticmethod
|
||||
def _verify_password(password: str, stored_hash: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
try:
|
||||
salt, hash_value = stored_hash.split(":")
|
||||
hash_input = f"{salt}:{password}".encode()
|
||||
computed_hash = hashlib.sha256(hash_input).hexdigest()
|
||||
return secrets.compare_digest(computed_hash, hash_value)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
email: Optional[str] = None,
|
||||
role: UserRole = UserRole.USER,
|
||||
invited_by: Optional[str] = None,
|
||||
) -> Optional[User]:
|
||||
"""Create a new user account."""
|
||||
user_id = secrets.token_hex(16)
|
||||
password_hash = self._hash_password(password)
|
||||
|
||||
try:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO users (id, username, email, password_hash, role, invited_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(user_id, username, email, password_hash, role.value, invited_by),
|
||||
)
|
||||
return self.get_user_by_id(user_id)
|
||||
except sqlite3.IntegrityError:
|
||||
return None # Username or email already exists
|
||||
|
||||
def get_user_by_id(self, user_id: str) -> Optional[User]:
|
||||
"""Get user by ID."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM users WHERE id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return self._row_to_user(row)
|
||||
return None
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||
"""Get user by username."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM users WHERE username = ?",
|
||||
(username,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return self._row_to_user(row)
|
||||
return None
|
||||
|
||||
def _row_to_user(self, row: sqlite3.Row) -> User:
|
||||
"""Convert database row to User object."""
|
||||
return User(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
email=row["email"],
|
||||
password_hash=row["password_hash"],
|
||||
role=UserRole(row["role"]),
|
||||
created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else None,
|
||||
last_login=datetime.fromisoformat(row["last_login"]) if row["last_login"] else None,
|
||||
is_active=bool(row["is_active"]),
|
||||
invited_by=row["invited_by"],
|
||||
)
|
||||
|
||||
def authenticate(self, username: str, password: str) -> Optional[User]:
|
||||
"""Authenticate user with username and password."""
|
||||
user = self.get_user_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
if not user.is_active:
|
||||
return None
|
||||
if not self._verify_password(password, user.password_hash):
|
||||
return None
|
||||
|
||||
# Update last login
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET last_login = ? WHERE id = ?",
|
||||
(datetime.now(), user.id)
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
def create_session(self, user: User, duration_hours: int = 24) -> Session:
|
||||
"""Create a new session for a user."""
|
||||
token = secrets.token_urlsafe(32)
|
||||
created_at = datetime.now()
|
||||
expires_at = created_at + timedelta(hours=duration_hours)
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sessions (token, user_id, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(token, user.id, created_at, expires_at)
|
||||
)
|
||||
|
||||
return Session(
|
||||
token=token,
|
||||
user_id=user.id,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
def get_session(self, token: str) -> Optional[Session]:
|
||||
"""Get session by token."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM sessions WHERE token = ?",
|
||||
(token,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
session = Session(
|
||||
token=row["token"],
|
||||
user_id=row["user_id"],
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
expires_at=datetime.fromisoformat(row["expires_at"]),
|
||||
)
|
||||
if not session.is_expired():
|
||||
return session
|
||||
# Clean up expired session
|
||||
self.invalidate_session(token)
|
||||
return None
|
||||
|
||||
def get_user_from_session(self, token: str) -> Optional[User]:
|
||||
"""Get user from session token."""
|
||||
session = self.get_session(token)
|
||||
if session:
|
||||
return self.get_user_by_id(session.user_id)
|
||||
return None
|
||||
|
||||
def invalidate_session(self, token: str):
|
||||
"""Invalidate a session."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
|
||||
|
||||
def invalidate_user_sessions(self, user_id: str):
|
||||
"""Invalidate all sessions for a user."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
|
||||
|
||||
# =========================================================================
|
||||
# Invite Codes
|
||||
# =========================================================================
|
||||
|
||||
def create_invite_code(
|
||||
self,
|
||||
created_by: str,
|
||||
max_uses: int = 1,
|
||||
expires_in_days: Optional[int] = 7,
|
||||
) -> InviteCode:
|
||||
"""Create a new invite code."""
|
||||
code = secrets.token_urlsafe(8).upper()[:8] # 8 character code
|
||||
created_at = datetime.now()
|
||||
expires_at = created_at + timedelta(days=expires_in_days) if expires_in_days else None
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO invite_codes (code, created_by, created_at, expires_at, max_uses)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(code, created_by, created_at, expires_at, max_uses)
|
||||
)
|
||||
|
||||
return InviteCode(
|
||||
code=code,
|
||||
created_by=created_by,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
max_uses=max_uses,
|
||||
use_count=0,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def get_invite_code(self, code: str) -> Optional[InviteCode]:
|
||||
"""Get invite code by code string."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM invite_codes WHERE code = ?",
|
||||
(code.upper(),)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return InviteCode(
|
||||
code=row["code"],
|
||||
created_by=row["created_by"],
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
expires_at=datetime.fromisoformat(row["expires_at"]) if row["expires_at"] else None,
|
||||
max_uses=row["max_uses"],
|
||||
use_count=row["use_count"],
|
||||
is_active=bool(row["is_active"]),
|
||||
)
|
||||
return None
|
||||
|
||||
def use_invite_code(self, code: str) -> bool:
|
||||
"""Mark an invite code as used. Returns False if invalid."""
|
||||
invite = self.get_invite_code(code)
|
||||
if not invite or not invite.is_valid():
|
||||
return False
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE invite_codes SET use_count = use_count + 1 WHERE code = ?",
|
||||
(code.upper(),)
|
||||
)
|
||||
return True
|
||||
|
||||
def validate_room_code_as_invite(self, room_code: str) -> bool:
|
||||
"""
|
||||
Check if a room code is valid for registration.
|
||||
Room codes from active games act as invite codes.
|
||||
"""
|
||||
# First check if it's an explicit invite code
|
||||
invite = self.get_invite_code(room_code)
|
||||
if invite and invite.is_valid():
|
||||
return True
|
||||
|
||||
# Check if it's an active room code (from room manager)
|
||||
# This will be checked by the caller since we don't have room_manager here
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# Admin Functions
|
||||
# =========================================================================
|
||||
|
||||
def list_users(self, include_inactive: bool = False) -> list[User]:
|
||||
"""List all users (admin function)."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
if include_inactive:
|
||||
cursor = conn.execute("SELECT * FROM users ORDER BY created_at DESC")
|
||||
else:
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM users WHERE is_active = 1 ORDER BY created_at DESC"
|
||||
)
|
||||
return [self._row_to_user(row) for row in cursor.fetchall()]
|
||||
|
||||
def update_user(
|
||||
self,
|
||||
user_id: str,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
role: Optional[UserRole] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> Optional[User]:
|
||||
"""Update user details (admin function)."""
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if username is not None:
|
||||
updates.append("username = ?")
|
||||
params.append(username)
|
||||
if email is not None:
|
||||
updates.append("email = ?")
|
||||
params.append(email)
|
||||
if role is not None:
|
||||
updates.append("role = ?")
|
||||
params.append(role.value)
|
||||
if is_active is not None:
|
||||
updates.append("is_active = ?")
|
||||
params.append(is_active)
|
||||
|
||||
if not updates:
|
||||
return self.get_user_by_id(user_id)
|
||||
|
||||
params.append(user_id)
|
||||
|
||||
try:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
f"UPDATE users SET {', '.join(updates)} WHERE id = ?",
|
||||
params
|
||||
)
|
||||
return self.get_user_by_id(user_id)
|
||||
except sqlite3.IntegrityError:
|
||||
return None
|
||||
|
||||
def change_password(self, user_id: str, new_password: str) -> bool:
|
||||
"""Change user password."""
|
||||
password_hash = self._hash_password(new_password)
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.execute(
|
||||
"UPDATE users SET password_hash = ? WHERE id = ?",
|
||||
(password_hash, user_id)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_user(self, user_id: str) -> bool:
|
||||
"""Delete a user (admin function). Actually just deactivates."""
|
||||
# Invalidate all sessions first
|
||||
self.invalidate_user_sessions(user_id)
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.execute(
|
||||
"UPDATE users SET is_active = 0 WHERE id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def list_invite_codes(self, created_by: Optional[str] = None) -> list[InviteCode]:
|
||||
"""List invite codes (admin function)."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
if created_by:
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM invite_codes WHERE created_by = ? ORDER BY created_at DESC",
|
||||
(created_by,)
|
||||
)
|
||||
else:
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM invite_codes ORDER BY created_at DESC"
|
||||
)
|
||||
return [
|
||||
InviteCode(
|
||||
code=row["code"],
|
||||
created_by=row["created_by"],
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
expires_at=datetime.fromisoformat(row["expires_at"]) if row["expires_at"] else None,
|
||||
max_uses=row["max_uses"],
|
||||
use_count=row["use_count"],
|
||||
is_active=bool(row["is_active"]),
|
||||
)
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
|
||||
def deactivate_invite_code(self, code: str) -> bool:
|
||||
"""Deactivate an invite code (admin function)."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.execute(
|
||||
"UPDATE invite_codes SET is_active = 0 WHERE code = ?",
|
||||
(code.upper(),)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def cleanup_expired_sessions(self):
|
||||
"""Remove expired sessions from database."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM sessions WHERE expires_at < ?",
|
||||
(datetime.now(),)
|
||||
)
|
||||
|
||||
|
||||
# Global auth manager instance (lazy initialization)
|
||||
_auth_manager: Optional[AuthManager] = None
|
||||
|
||||
|
||||
def get_auth_manager() -> AuthManager:
|
||||
"""Get or create the global auth manager instance."""
|
||||
global _auth_manager
|
||||
if _auth_manager is None:
|
||||
_auth_manager = AuthManager()
|
||||
return _auth_manager
|
||||
176
server/config.py
Normal file
176
server/config.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Centralized configuration for Golf game server.
|
||||
|
||||
Configuration is loaded from (in order of precedence):
|
||||
1. Environment variables
|
||||
2. .env file (if exists)
|
||||
3. Default values
|
||||
|
||||
Usage:
|
||||
from config import config
|
||||
print(config.PORT)
|
||||
print(config.card_values)
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Load .env file if it exists
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
env_path = Path(__file__).parent.parent / ".env"
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path)
|
||||
except ImportError:
|
||||
pass # python-dotenv not installed, use env vars only
|
||||
|
||||
|
||||
def get_env(key: str, default: str = "") -> str:
|
||||
"""Get environment variable with default."""
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
def get_env_bool(key: str, default: bool = False) -> bool:
|
||||
"""Get boolean environment variable."""
|
||||
val = os.environ.get(key, "").lower()
|
||||
if val in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if val in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def get_env_int(key: str, default: int = 0) -> int:
|
||||
"""Get integer environment variable."""
|
||||
try:
|
||||
return int(os.environ.get(key, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
@dataclass
|
||||
class CardValues:
|
||||
"""Card point values - the single source of truth."""
|
||||
ACE: int = 1
|
||||
TWO: int = -2
|
||||
THREE: int = 3
|
||||
FOUR: int = 4
|
||||
FIVE: int = 5
|
||||
SIX: int = 6
|
||||
SEVEN: int = 7
|
||||
EIGHT: int = 8
|
||||
NINE: int = 9
|
||||
TEN: int = 10
|
||||
JACK: int = 10
|
||||
QUEEN: int = 10
|
||||
KING: int = 0
|
||||
JOKER: int = -2
|
||||
|
||||
# House rule modifiers
|
||||
SUPER_KINGS: int = -2 # King value when super_kings enabled
|
||||
TEN_PENNY: int = 1 # 10 value when ten_penny enabled
|
||||
LUCKY_SWING_JOKER: int = -5 # Joker value when lucky_swing enabled
|
||||
|
||||
def to_dict(self) -> dict[str, int]:
|
||||
"""Get card values as dictionary for game use."""
|
||||
return {
|
||||
'A': self.ACE,
|
||||
'2': self.TWO,
|
||||
'3': self.THREE,
|
||||
'4': self.FOUR,
|
||||
'5': self.FIVE,
|
||||
'6': self.SIX,
|
||||
'7': self.SEVEN,
|
||||
'8': self.EIGHT,
|
||||
'9': self.NINE,
|
||||
'10': self.TEN,
|
||||
'J': self.JACK,
|
||||
'Q': self.QUEEN,
|
||||
'K': self.KING,
|
||||
'★': self.JOKER,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameDefaults:
|
||||
"""Default game settings."""
|
||||
rounds: int = 9
|
||||
initial_flips: int = 2
|
||||
use_jokers: bool = False
|
||||
flip_on_discard: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
"""Server configuration."""
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
DEBUG: bool = False
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "sqlite:///games.db"
|
||||
|
||||
# Room settings
|
||||
MAX_PLAYERS_PER_ROOM: int = 6
|
||||
ROOM_TIMEOUT_MINUTES: int = 60
|
||||
ROOM_CODE_LENGTH: int = 4
|
||||
|
||||
# Security (for future auth system)
|
||||
SECRET_KEY: str = ""
|
||||
INVITE_ONLY: bool = False
|
||||
ADMIN_EMAILS: list[str] = field(default_factory=list)
|
||||
|
||||
# Card values
|
||||
card_values: CardValues = field(default_factory=CardValues)
|
||||
|
||||
# Game defaults
|
||||
game_defaults: GameDefaults = field(default_factory=GameDefaults)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "ServerConfig":
|
||||
"""Load configuration from environment variables."""
|
||||
admin_emails_str = get_env("ADMIN_EMAILS", "")
|
||||
admin_emails = [e.strip() for e in admin_emails_str.split(",") if e.strip()]
|
||||
|
||||
return cls(
|
||||
HOST=get_env("HOST", "0.0.0.0"),
|
||||
PORT=get_env_int("PORT", 8000),
|
||||
DEBUG=get_env_bool("DEBUG", False),
|
||||
LOG_LEVEL=get_env("LOG_LEVEL", "INFO"),
|
||||
DATABASE_URL=get_env("DATABASE_URL", "sqlite:///games.db"),
|
||||
MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6),
|
||||
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
|
||||
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
|
||||
SECRET_KEY=get_env("SECRET_KEY", ""),
|
||||
INVITE_ONLY=get_env_bool("INVITE_ONLY", False),
|
||||
ADMIN_EMAILS=admin_emails,
|
||||
card_values=CardValues(
|
||||
ACE=get_env_int("CARD_ACE", 1),
|
||||
TWO=get_env_int("CARD_TWO", -2),
|
||||
KING=get_env_int("CARD_KING", 0),
|
||||
JOKER=get_env_int("CARD_JOKER", -2),
|
||||
SUPER_KINGS=get_env_int("CARD_SUPER_KINGS", -2),
|
||||
TEN_PENNY=get_env_int("CARD_TEN_PENNY", 1),
|
||||
LUCKY_SWING_JOKER=get_env_int("CARD_LUCKY_SWING_JOKER", -5),
|
||||
),
|
||||
game_defaults=GameDefaults(
|
||||
rounds=get_env_int("DEFAULT_ROUNDS", 9),
|
||||
initial_flips=get_env_int("DEFAULT_INITIAL_FLIPS", 2),
|
||||
use_jokers=get_env_bool("DEFAULT_USE_JOKERS", False),
|
||||
flip_on_discard=get_env_bool("DEFAULT_FLIP_ON_DISCARD", False),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Global config instance - loaded once at module import
|
||||
config = ServerConfig.from_env()
|
||||
|
||||
|
||||
def reload_config() -> ServerConfig:
|
||||
"""Reload configuration from environment (useful for testing)."""
|
||||
global config
|
||||
config = ServerConfig.from_env()
|
||||
return config
|
||||
@@ -4,6 +4,9 @@ Card value constants for 6-Card Golf.
|
||||
This module is the single source of truth for all card point values.
|
||||
House rule modifications are defined here and applied in game.py.
|
||||
|
||||
Configuration can be customized via environment variables.
|
||||
See config.py and .env.example for details.
|
||||
|
||||
Standard Golf Scoring:
|
||||
- Ace: 1 point
|
||||
- Two: -2 points (special - only negative non-joker)
|
||||
@@ -15,29 +18,72 @@ Standard Golf Scoring:
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# Base card values (no house rules applied)
|
||||
DEFAULT_CARD_VALUES: dict[str, int] = {
|
||||
'A': 1,
|
||||
'2': -2,
|
||||
'3': 3,
|
||||
'4': 4,
|
||||
'5': 5,
|
||||
'6': 6,
|
||||
'7': 7,
|
||||
'8': 8,
|
||||
'9': 9,
|
||||
'10': 10,
|
||||
'J': 10,
|
||||
'Q': 10,
|
||||
'K': 0,
|
||||
'★': -2, # Joker (standard mode)
|
||||
}
|
||||
# Try to load from config (which reads env vars), fall back to hardcoded defaults
|
||||
try:
|
||||
from config import config
|
||||
_use_config = True
|
||||
except ImportError:
|
||||
_use_config = False
|
||||
|
||||
# --- House Rule Value Overrides ---
|
||||
SUPER_KINGS_VALUE: int = -2 # Kings worth -2 instead of 0
|
||||
TEN_PENNY_VALUE: int = 1 # 10s worth 1 instead of 10
|
||||
LUCKY_SWING_JOKER_VALUE: int = -5 # Single joker worth -5
|
||||
|
||||
# =============================================================================
|
||||
# Card Values - Single Source of Truth
|
||||
# =============================================================================
|
||||
|
||||
if _use_config:
|
||||
# Load from environment-aware config
|
||||
DEFAULT_CARD_VALUES: dict[str, int] = config.card_values.to_dict()
|
||||
SUPER_KINGS_VALUE: int = config.card_values.SUPER_KINGS
|
||||
TEN_PENNY_VALUE: int = config.card_values.TEN_PENNY
|
||||
LUCKY_SWING_JOKER_VALUE: int = config.card_values.LUCKY_SWING_JOKER
|
||||
else:
|
||||
# Hardcoded defaults (fallback)
|
||||
DEFAULT_CARD_VALUES: dict[str, int] = {
|
||||
'A': 1,
|
||||
'2': -2,
|
||||
'3': 3,
|
||||
'4': 4,
|
||||
'5': 5,
|
||||
'6': 6,
|
||||
'7': 7,
|
||||
'8': 8,
|
||||
'9': 9,
|
||||
'10': 10,
|
||||
'J': 10,
|
||||
'Q': 10,
|
||||
'K': 0,
|
||||
'★': -2, # Joker (standard mode)
|
||||
}
|
||||
SUPER_KINGS_VALUE: int = -2 # Kings worth -2 instead of 0
|
||||
TEN_PENNY_VALUE: int = 1 # 10s worth 1 instead of 10
|
||||
LUCKY_SWING_JOKER_VALUE: int = -5 # Single joker worth -5
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Game Constants
|
||||
# =============================================================================
|
||||
|
||||
if _use_config:
|
||||
MAX_PLAYERS = config.MAX_PLAYERS_PER_ROOM
|
||||
ROOM_CODE_LENGTH = config.ROOM_CODE_LENGTH
|
||||
ROOM_TIMEOUT_MINUTES = config.ROOM_TIMEOUT_MINUTES
|
||||
DEFAULT_ROUNDS = config.game_defaults.rounds
|
||||
DEFAULT_INITIAL_FLIPS = config.game_defaults.initial_flips
|
||||
DEFAULT_USE_JOKERS = config.game_defaults.use_jokers
|
||||
DEFAULT_FLIP_ON_DISCARD = config.game_defaults.flip_on_discard
|
||||
else:
|
||||
MAX_PLAYERS = 6
|
||||
ROOM_CODE_LENGTH = 4
|
||||
ROOM_TIMEOUT_MINUTES = 60
|
||||
DEFAULT_ROUNDS = 9
|
||||
DEFAULT_INITIAL_FLIPS = 2
|
||||
DEFAULT_USE_JOKERS = False
|
||||
DEFAULT_FLIP_ON_DISCARD = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_card_value_for_rank(
|
||||
rank_str: str,
|
||||
|
||||
@@ -76,14 +76,11 @@ class GameLogger:
|
||||
"use_jokers": options.use_jokers,
|
||||
"lucky_swing": options.lucky_swing,
|
||||
"super_kings": options.super_kings,
|
||||
"lucky_sevens": options.lucky_sevens,
|
||||
"ten_penny": options.ten_penny,
|
||||
"knock_bonus": options.knock_bonus,
|
||||
"underdog_bonus": options.underdog_bonus,
|
||||
"tied_shame": options.tied_shame,
|
||||
"blackjack": options.blackjack,
|
||||
"queens_wild": options.queens_wild,
|
||||
"four_of_a_kind": options.four_of_a_kind,
|
||||
"eagle_eye": options.eagle_eye,
|
||||
}
|
||||
|
||||
|
||||
BIN
server/games.db
Normal file
BIN
server/games.db
Normal file
Binary file not shown.
427
server/main.py
427
server/main.py
@@ -1,18 +1,33 @@
|
||||
"""FastAPI WebSocket server for Golf card game."""
|
||||
|
||||
import uuid
|
||||
import asyncio
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, Header
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config import config
|
||||
from room import RoomManager, Room
|
||||
from game import GamePhase, GameOptions
|
||||
from ai import GolfAI, process_cpu_turn, get_all_profiles
|
||||
from game_log import get_logger
|
||||
from auth import get_auth_manager, User, UserRole
|
||||
|
||||
app = FastAPI(title="Golf Card Game")
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, config.LOG_LEVEL.upper(), logging.INFO),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="Golf Card Game",
|
||||
debug=config.DEBUG,
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
room_manager = RoomManager()
|
||||
|
||||
@@ -22,6 +37,374 @@ async def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auth Models
|
||||
# =============================================================================
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: Optional[str] = None
|
||||
invite_code: str # Room code or explicit invite code
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class SetupPasswordRequest(BaseModel):
|
||||
username: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class CreateInviteRequest(BaseModel):
|
||||
max_uses: int = 1
|
||||
expires_in_days: Optional[int] = 7
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auth Dependencies
|
||||
# =============================================================================
|
||||
|
||||
async def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[User]:
|
||||
"""Get current user from Authorization header."""
|
||||
if not authorization:
|
||||
return None
|
||||
|
||||
# Expect "Bearer <token>"
|
||||
parts = authorization.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return None
|
||||
|
||||
token = parts[1]
|
||||
auth = get_auth_manager()
|
||||
return auth.get_user_from_session(token)
|
||||
|
||||
|
||||
async def require_user(user: Optional[User] = Depends(get_current_user)) -> User:
|
||||
"""Require authenticated user."""
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account disabled")
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(user: User = Depends(require_user)) -> User:
|
||||
"""Require admin user."""
|
||||
if not user.is_admin():
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auth Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@app.post("/api/auth/register")
|
||||
async def register(request: RegisterRequest):
|
||||
"""Register a new user with an invite code."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
# Validate invite code
|
||||
invite_valid = False
|
||||
inviter_username = None
|
||||
|
||||
# Check if it's an explicit invite code
|
||||
invite = auth.get_invite_code(request.invite_code)
|
||||
if invite and invite.is_valid():
|
||||
invite_valid = True
|
||||
inviter = auth.get_user_by_id(invite.created_by)
|
||||
inviter_username = inviter.username if inviter else None
|
||||
|
||||
# Check if it's a valid room code
|
||||
if not invite_valid:
|
||||
room = room_manager.get_room(request.invite_code.upper())
|
||||
if room:
|
||||
invite_valid = True
|
||||
# Room codes are like open invites
|
||||
|
||||
if not invite_valid:
|
||||
raise HTTPException(status_code=400, detail="Invalid invite code")
|
||||
|
||||
# Create user
|
||||
user = auth.create_user(
|
||||
username=request.username,
|
||||
password=request.password,
|
||||
email=request.email,
|
||||
invited_by=inviter_username,
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Username or email already taken")
|
||||
|
||||
# Mark invite code as used (if it was an explicit invite)
|
||||
if invite:
|
||||
auth.use_invite_code(request.invite_code)
|
||||
|
||||
# Create session
|
||||
session = auth.create_session(user)
|
||||
|
||||
return {
|
||||
"user": user.to_dict(),
|
||||
"token": session.token,
|
||||
"expires_at": session.expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def login(request: LoginRequest):
|
||||
"""Login with username and password."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
# Check if user needs password setup (first login)
|
||||
if auth.needs_password_setup(request.username):
|
||||
raise HTTPException(
|
||||
status_code=428, # Precondition Required
|
||||
detail="Password setup required. Use /api/auth/setup-password endpoint."
|
||||
)
|
||||
|
||||
user = auth.authenticate(request.username, request.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
session = auth.create_session(user)
|
||||
|
||||
return {
|
||||
"user": user.to_dict(),
|
||||
"token": session.token,
|
||||
"expires_at": session.expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/auth/setup-password")
|
||||
async def setup_password(request: SetupPasswordRequest):
|
||||
"""Set password for first-time login (admin accounts created without password)."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
# Verify user exists and needs setup
|
||||
if not auth.needs_password_setup(request.username):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Password setup not available for this account"
|
||||
)
|
||||
|
||||
# Set the password
|
||||
user = auth.setup_password(request.username, request.new_password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Setup failed")
|
||||
|
||||
# Create session
|
||||
session = auth.create_session(user)
|
||||
|
||||
return {
|
||||
"user": user.to_dict(),
|
||||
"token": session.token,
|
||||
"expires_at": session.expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/auth/check-setup/{username}")
|
||||
async def check_setup_needed(username: str):
|
||||
"""Check if a username needs password setup."""
|
||||
auth = get_auth_manager()
|
||||
needs_setup = auth.needs_password_setup(username)
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"needs_password_setup": needs_setup,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def logout(authorization: Optional[str] = Header(None)):
|
||||
"""Logout current session."""
|
||||
if authorization:
|
||||
parts = authorization.split()
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
auth = get_auth_manager()
|
||||
auth.invalidate_session(parts[1])
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
async def get_me(user: User = Depends(require_user)):
|
||||
"""Get current user info."""
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.put("/api/auth/password")
|
||||
async def change_own_password(
|
||||
request: ChangePasswordRequest,
|
||||
user: User = Depends(require_user)
|
||||
):
|
||||
"""Change own password."""
|
||||
auth = get_auth_manager()
|
||||
auth.change_password(user.id, request.new_password)
|
||||
# Invalidate all other sessions
|
||||
auth.invalidate_user_sessions(user.id)
|
||||
# Create new session
|
||||
session = auth.create_session(user)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"token": session.token,
|
||||
"expires_at": session.expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Admin Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@app.get("/api/admin/users")
|
||||
async def list_users(
|
||||
include_inactive: bool = False,
|
||||
admin: User = Depends(require_admin)
|
||||
):
|
||||
"""List all users (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
users = auth.list_users(include_inactive=include_inactive)
|
||||
return {"users": [u.to_dict() for u in users]}
|
||||
|
||||
|
||||
@app.get("/api/admin/users/{user_id}")
|
||||
async def get_user(user_id: str, admin: User = Depends(require_admin)):
|
||||
"""Get user by ID (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
user = auth.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.put("/api/admin/users/{user_id}")
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
request: UpdateUserRequest,
|
||||
admin: User = Depends(require_admin)
|
||||
):
|
||||
"""Update user (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
# Convert role string to enum if provided
|
||||
role = UserRole(request.role) if request.role else None
|
||||
|
||||
user = auth.update_user(
|
||||
user_id=user_id,
|
||||
username=request.username,
|
||||
email=request.email,
|
||||
role=role,
|
||||
is_active=request.is_active,
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Update failed (duplicate username/email?)")
|
||||
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.put("/api/admin/users/{user_id}/password")
|
||||
async def admin_change_password(
|
||||
user_id: str,
|
||||
request: ChangePasswordRequest,
|
||||
admin: User = Depends(require_admin)
|
||||
):
|
||||
"""Change user password (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
if not auth.change_password(user_id, request.new_password):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Invalidate all user sessions
|
||||
auth.invalidate_user_sessions(user_id)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/api/admin/users/{user_id}")
|
||||
async def delete_user(user_id: str, admin: User = Depends(require_admin)):
|
||||
"""Deactivate user (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
# Don't allow deleting yourself
|
||||
if user_id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
if not auth.delete_user(user_id):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/admin/invites")
|
||||
async def create_invite(
|
||||
request: CreateInviteRequest,
|
||||
admin: User = Depends(require_admin)
|
||||
):
|
||||
"""Create an invite code (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
invite = auth.create_invite_code(
|
||||
created_by=admin.id,
|
||||
max_uses=request.max_uses,
|
||||
expires_in_days=request.expires_in_days,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": invite.code,
|
||||
"max_uses": invite.max_uses,
|
||||
"expires_at": invite.expires_at.isoformat() if invite.expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/admin/invites")
|
||||
async def list_invites(admin: User = Depends(require_admin)):
|
||||
"""List all invite codes (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
invites = auth.list_invite_codes()
|
||||
|
||||
return {
|
||||
"invites": [
|
||||
{
|
||||
"code": i.code,
|
||||
"created_by": i.created_by,
|
||||
"created_at": i.created_at.isoformat(),
|
||||
"expires_at": i.expires_at.isoformat() if i.expires_at else None,
|
||||
"max_uses": i.max_uses,
|
||||
"use_count": i.use_count,
|
||||
"is_active": i.is_active,
|
||||
"is_valid": i.is_valid(),
|
||||
}
|
||||
for i in invites
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/admin/invites/{code}")
|
||||
async def deactivate_invite(code: str, admin: User = Depends(require_admin)):
|
||||
"""Deactivate an invite code (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
if not auth.deactivate_invite_code(code):
|
||||
raise HTTPException(status_code=404, detail="Invite code not found")
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
@@ -485,3 +868,35 @@ if os.path.exists(client_path):
|
||||
@app.get("/app.js")
|
||||
async def serve_js():
|
||||
return FileResponse(os.path.join(client_path, "app.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/card-manager.js")
|
||||
async def serve_card_manager():
|
||||
return FileResponse(os.path.join(client_path, "card-manager.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/state-differ.js")
|
||||
async def serve_state_differ():
|
||||
return FileResponse(os.path.join(client_path, "state-differ.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/animation-queue.js")
|
||||
async def serve_animation_queue():
|
||||
return FileResponse(os.path.join(client_path, "animation-queue.js"), media_type="application/javascript")
|
||||
|
||||
|
||||
def run():
|
||||
"""Run the server using uvicorn."""
|
||||
import uvicorn
|
||||
|
||||
logger.info(f"Starting Golf server on {config.HOST}:{config.PORT}")
|
||||
logger.info(f"Debug mode: {config.DEBUG}")
|
||||
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=config.HOST,
|
||||
port=config.PORT,
|
||||
reload=config.DEBUG,
|
||||
log_level=config.LOG_LEVEL.lower(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
websockets==12.0
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
websockets>=12.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
BIN
server/score_distribution.png
Normal file
BIN
server/score_distribution.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
@@ -20,8 +20,10 @@ from typing import Optional
|
||||
from game import Game, Player, GamePhase, GameOptions
|
||||
from ai import (
|
||||
GolfAI, CPUProfile, CPU_PROFILES,
|
||||
get_ai_card_value, has_worse_visible_card
|
||||
get_ai_card_value, has_worse_visible_card,
|
||||
filter_bad_pair_positions, get_column_partner_position
|
||||
)
|
||||
from game import Rank
|
||||
from game_log import GameLogger
|
||||
|
||||
|
||||
@@ -36,6 +38,15 @@ class SimulationStats:
|
||||
self.player_scores: dict[str, list[int]] = {}
|
||||
self.decisions: dict[str, dict] = {} # player -> {action: count}
|
||||
|
||||
# Dumb move tracking
|
||||
self.discarded_jokers = 0
|
||||
self.discarded_twos = 0
|
||||
self.discarded_kings = 0
|
||||
self.took_bad_card_without_pair = 0
|
||||
self.paired_negative_cards = 0
|
||||
self.swapped_good_for_bad = 0
|
||||
self.total_opportunities = 0 # Total decision points
|
||||
|
||||
def record_game(self, game: Game, winner_name: str):
|
||||
self.games_played += 1
|
||||
self.total_rounds += game.current_round
|
||||
@@ -57,6 +68,40 @@ class SimulationStats:
|
||||
self.decisions[player_name][action] = 0
|
||||
self.decisions[player_name][action] += 1
|
||||
|
||||
def record_dumb_move(self, move_type: str):
|
||||
"""Record a dumb move for analysis."""
|
||||
if move_type == "discarded_joker":
|
||||
self.discarded_jokers += 1
|
||||
elif move_type == "discarded_two":
|
||||
self.discarded_twos += 1
|
||||
elif move_type == "discarded_king":
|
||||
self.discarded_kings += 1
|
||||
elif move_type == "took_bad_without_pair":
|
||||
self.took_bad_card_without_pair += 1
|
||||
elif move_type == "paired_negative":
|
||||
self.paired_negative_cards += 1
|
||||
elif move_type == "swapped_good_for_bad":
|
||||
self.swapped_good_for_bad += 1
|
||||
|
||||
def record_opportunity(self):
|
||||
"""Record a decision opportunity for rate calculation."""
|
||||
self.total_opportunities += 1
|
||||
|
||||
@property
|
||||
def dumb_move_rate(self) -> float:
|
||||
"""Calculate overall dumb move rate."""
|
||||
total_dumb = (
|
||||
self.discarded_jokers +
|
||||
self.discarded_twos +
|
||||
self.discarded_kings +
|
||||
self.took_bad_card_without_pair +
|
||||
self.paired_negative_cards +
|
||||
self.swapped_good_for_bad
|
||||
)
|
||||
if self.total_opportunities == 0:
|
||||
return 0.0
|
||||
return total_dumb / self.total_opportunities * 100
|
||||
|
||||
def report(self) -> str:
|
||||
lines = [
|
||||
"=" * 50,
|
||||
@@ -95,6 +140,21 @@ class SimulationStats:
|
||||
pct = count / max(1, total) * 100
|
||||
lines.append(f" {action}: {count} ({pct:.1f}%)")
|
||||
|
||||
lines.append("")
|
||||
lines.append("DUMB MOVE ANALYSIS:")
|
||||
lines.append(f" Total decision opportunities: {self.total_opportunities}")
|
||||
lines.append(f" Dumb move rate: {self.dumb_move_rate:.3f}%")
|
||||
lines.append("")
|
||||
lines.append(" Blunders (should be 0):")
|
||||
lines.append(f" Discarded Jokers: {self.discarded_jokers}")
|
||||
lines.append(f" Discarded 2s: {self.discarded_twos}")
|
||||
lines.append(f" Took bad card without pair: {self.took_bad_card_without_pair}")
|
||||
lines.append(f" Paired negative cards: {self.paired_negative_cards}")
|
||||
lines.append("")
|
||||
lines.append(" Mistakes (should be < 0.1%):")
|
||||
lines.append(f" Discarded Kings: {self.discarded_kings}")
|
||||
lines.append(f" Swapped good for bad: {self.swapped_good_for_bad}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -134,6 +194,27 @@ def run_cpu_turn(
|
||||
action = "take_discard" if take_discard else "draw_deck"
|
||||
stats.record_turn(player.name, action)
|
||||
|
||||
# Check for dumb move: taking bad card from discard without good reason
|
||||
if take_discard:
|
||||
drawn_val = get_ai_card_value(drawn, game.options)
|
||||
# Bad cards are 8, 9, 10, J, Q (value >= 8)
|
||||
if drawn_val >= 8:
|
||||
# Check if there's pair potential
|
||||
has_pair_potential = False
|
||||
for i, card in enumerate(player.cards):
|
||||
if card.face_up and card.rank == drawn.rank:
|
||||
partner_pos = get_column_partner_position(i)
|
||||
if not player.cards[partner_pos].face_up:
|
||||
has_pair_potential = True
|
||||
break
|
||||
|
||||
# Check if player has a WORSE visible card to replace
|
||||
has_worse_to_replace = has_worse_visible_card(player, drawn_val, game.options)
|
||||
|
||||
# Only flag as dumb if no pair potential AND no worse card to replace
|
||||
if not has_pair_potential and not has_worse_to_replace:
|
||||
stats.record_dumb_move("took_bad_without_pair")
|
||||
|
||||
# Log draw decision
|
||||
if logger and game_id:
|
||||
reason = f"took {discard_top.rank.value} from discard" if take_discard else "drew from deck"
|
||||
@@ -154,7 +235,9 @@ def run_cpu_turn(
|
||||
if swap_pos is None and game.drawn_from_discard:
|
||||
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||
if face_down:
|
||||
swap_pos = random.choice(face_down)
|
||||
# Use filter to avoid bad pairs with negative cards
|
||||
safe_positions = filter_bad_pair_positions(face_down, drawn, player, game.options)
|
||||
swap_pos = random.choice(safe_positions)
|
||||
else:
|
||||
# Find worst card using house rules
|
||||
worst_pos = 0
|
||||
@@ -166,8 +249,27 @@ def run_cpu_turn(
|
||||
worst_pos = i
|
||||
swap_pos = worst_pos
|
||||
|
||||
# Record this as a decision opportunity for dumb move rate calculation
|
||||
stats.record_opportunity()
|
||||
|
||||
if swap_pos is not None:
|
||||
old_card = player.cards[swap_pos]
|
||||
|
||||
# Check for dumb moves: swapping good card for bad
|
||||
drawn_val = get_ai_card_value(drawn, game.options)
|
||||
old_val = get_ai_card_value(old_card, game.options)
|
||||
if old_card.face_up and old_val < drawn_val and old_val <= 1:
|
||||
stats.record_dumb_move("swapped_good_for_bad")
|
||||
|
||||
# Check for dumb move: creating bad pair with negative card
|
||||
partner_pos = get_column_partner_position(swap_pos)
|
||||
partner = player.cards[partner_pos]
|
||||
if (partner.face_up and
|
||||
partner.rank == drawn.rank and
|
||||
drawn_val < 0 and
|
||||
not (game.options.eagle_eye and drawn.rank == Rank.JOKER)):
|
||||
stats.record_dumb_move("paired_negative")
|
||||
|
||||
game.swap_card(player.id, swap_pos)
|
||||
action = "swap"
|
||||
stats.record_turn(player.name, action)
|
||||
@@ -184,6 +286,14 @@ def run_cpu_turn(
|
||||
decision_reason=f"swapped {drawn.rank.value} for {old_card.rank.value} at pos {swap_pos}",
|
||||
)
|
||||
else:
|
||||
# Check for dumb moves: discarding excellent cards
|
||||
if drawn.rank == Rank.JOKER:
|
||||
stats.record_dumb_move("discarded_joker")
|
||||
elif drawn.rank == Rank.TWO:
|
||||
stats.record_dumb_move("discarded_two")
|
||||
elif drawn.rank == Rank.KING:
|
||||
stats.record_dumb_move("discarded_king")
|
||||
|
||||
game.discard_drawn(player.id)
|
||||
action = "discard"
|
||||
stats.record_turn(player.name, action)
|
||||
|
||||
@@ -40,9 +40,6 @@ class TestCardValues:
|
||||
opts = {'super_kings': True}
|
||||
assert get_card_value('K', opts) == -2
|
||||
|
||||
opts = {'lucky_sevens': True}
|
||||
assert get_card_value('7', opts) == 0
|
||||
|
||||
opts = {'ten_penny': True}
|
||||
assert get_card_value('10', opts) == 1
|
||||
|
||||
|
||||
288
server/test_auth.py
Normal file
288
server/test_auth.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Tests for the authentication system.
|
||||
|
||||
Run with: pytest test_auth.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from auth import AuthManager, User, UserRole, Session, InviteCode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_manager():
|
||||
"""Create a fresh auth manager with temporary database."""
|
||||
# Use a temporary file for testing
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
|
||||
# Create manager (this will create default admin)
|
||||
manager = AuthManager(db_path=path)
|
||||
|
||||
yield manager
|
||||
|
||||
# Cleanup
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
class TestUserCreation:
|
||||
"""Test user creation and retrieval."""
|
||||
|
||||
def test_create_user(self, auth_manager):
|
||||
"""Can create a new user."""
|
||||
user = auth_manager.create_user(
|
||||
username="testuser",
|
||||
password="password123",
|
||||
email="test@example.com",
|
||||
)
|
||||
|
||||
assert user is not None
|
||||
assert user.username == "testuser"
|
||||
assert user.email == "test@example.com"
|
||||
assert user.role == UserRole.USER
|
||||
assert user.is_active is True
|
||||
|
||||
def test_create_duplicate_username_fails(self, auth_manager):
|
||||
"""Cannot create user with duplicate username."""
|
||||
auth_manager.create_user(username="testuser", password="pass1")
|
||||
user2 = auth_manager.create_user(username="testuser", password="pass2")
|
||||
|
||||
assert user2 is None
|
||||
|
||||
def test_create_duplicate_email_fails(self, auth_manager):
|
||||
"""Cannot create user with duplicate email."""
|
||||
auth_manager.create_user(
|
||||
username="user1",
|
||||
password="pass1",
|
||||
email="test@example.com"
|
||||
)
|
||||
user2 = auth_manager.create_user(
|
||||
username="user2",
|
||||
password="pass2",
|
||||
email="test@example.com"
|
||||
)
|
||||
|
||||
assert user2 is None
|
||||
|
||||
def test_create_admin_user(self, auth_manager):
|
||||
"""Can create admin user."""
|
||||
user = auth_manager.create_user(
|
||||
username="newadmin",
|
||||
password="adminpass",
|
||||
role=UserRole.ADMIN,
|
||||
)
|
||||
|
||||
assert user is not None
|
||||
assert user.is_admin() is True
|
||||
|
||||
def test_get_user_by_id(self, auth_manager):
|
||||
"""Can retrieve user by ID."""
|
||||
created = auth_manager.create_user(username="testuser", password="pass")
|
||||
retrieved = auth_manager.get_user_by_id(created.id)
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.username == "testuser"
|
||||
|
||||
def test_get_user_by_username(self, auth_manager):
|
||||
"""Can retrieve user by username."""
|
||||
auth_manager.create_user(username="testuser", password="pass")
|
||||
retrieved = auth_manager.get_user_by_username("testuser")
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.username == "testuser"
|
||||
|
||||
|
||||
class TestAuthentication:
|
||||
"""Test login and session management."""
|
||||
|
||||
def test_authenticate_valid_credentials(self, auth_manager):
|
||||
"""Can authenticate with valid credentials."""
|
||||
auth_manager.create_user(username="testuser", password="correctpass")
|
||||
user = auth_manager.authenticate("testuser", "correctpass")
|
||||
|
||||
assert user is not None
|
||||
assert user.username == "testuser"
|
||||
|
||||
def test_authenticate_invalid_password(self, auth_manager):
|
||||
"""Invalid password returns None."""
|
||||
auth_manager.create_user(username="testuser", password="correctpass")
|
||||
user = auth_manager.authenticate("testuser", "wrongpass")
|
||||
|
||||
assert user is None
|
||||
|
||||
def test_authenticate_nonexistent_user(self, auth_manager):
|
||||
"""Nonexistent user returns None."""
|
||||
user = auth_manager.authenticate("nonexistent", "anypass")
|
||||
|
||||
assert user is None
|
||||
|
||||
def test_authenticate_inactive_user(self, auth_manager):
|
||||
"""Inactive user cannot authenticate."""
|
||||
created = auth_manager.create_user(username="testuser", password="pass")
|
||||
auth_manager.update_user(created.id, is_active=False)
|
||||
|
||||
user = auth_manager.authenticate("testuser", "pass")
|
||||
|
||||
assert user is None
|
||||
|
||||
def test_create_session(self, auth_manager):
|
||||
"""Can create session for authenticated user."""
|
||||
user = auth_manager.create_user(username="testuser", password="pass")
|
||||
session = auth_manager.create_session(user)
|
||||
|
||||
assert session is not None
|
||||
assert session.user_id == user.id
|
||||
assert session.is_expired() is False
|
||||
|
||||
def test_get_user_from_session(self, auth_manager):
|
||||
"""Can get user from valid session token."""
|
||||
user = auth_manager.create_user(username="testuser", password="pass")
|
||||
session = auth_manager.create_session(user)
|
||||
|
||||
retrieved = auth_manager.get_user_from_session(session.token)
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.id == user.id
|
||||
|
||||
def test_invalid_session_token(self, auth_manager):
|
||||
"""Invalid session token returns None."""
|
||||
user = auth_manager.get_user_from_session("invalid_token")
|
||||
|
||||
assert user is None
|
||||
|
||||
def test_invalidate_session(self, auth_manager):
|
||||
"""Can invalidate a session."""
|
||||
user = auth_manager.create_user(username="testuser", password="pass")
|
||||
session = auth_manager.create_session(user)
|
||||
|
||||
auth_manager.invalidate_session(session.token)
|
||||
retrieved = auth_manager.get_user_from_session(session.token)
|
||||
|
||||
assert retrieved is None
|
||||
|
||||
|
||||
class TestInviteCodes:
|
||||
"""Test invite code functionality."""
|
||||
|
||||
def test_create_invite_code(self, auth_manager):
|
||||
"""Can create invite code."""
|
||||
admin = auth_manager.get_user_by_username("admin")
|
||||
invite = auth_manager.create_invite_code(created_by=admin.id)
|
||||
|
||||
assert invite is not None
|
||||
assert len(invite.code) == 8
|
||||
assert invite.is_valid() is True
|
||||
|
||||
def test_use_invite_code(self, auth_manager):
|
||||
"""Can use invite code."""
|
||||
admin = auth_manager.get_user_by_username("admin")
|
||||
invite = auth_manager.create_invite_code(created_by=admin.id, max_uses=1)
|
||||
|
||||
result = auth_manager.use_invite_code(invite.code)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Check use count increased
|
||||
updated = auth_manager.get_invite_code(invite.code)
|
||||
assert updated.use_count == 1
|
||||
|
||||
def test_invite_code_max_uses(self, auth_manager):
|
||||
"""Invite code respects max uses."""
|
||||
admin = auth_manager.get_user_by_username("admin")
|
||||
invite = auth_manager.create_invite_code(created_by=admin.id, max_uses=1)
|
||||
|
||||
# First use should work
|
||||
auth_manager.use_invite_code(invite.code)
|
||||
|
||||
# Second use should fail (max_uses=1)
|
||||
updated = auth_manager.get_invite_code(invite.code)
|
||||
assert updated.is_valid() is False
|
||||
|
||||
def test_invite_code_case_insensitive(self, auth_manager):
|
||||
"""Invite code lookup is case insensitive."""
|
||||
admin = auth_manager.get_user_by_username("admin")
|
||||
invite = auth_manager.create_invite_code(created_by=admin.id)
|
||||
|
||||
retrieved_lower = auth_manager.get_invite_code(invite.code.lower())
|
||||
retrieved_upper = auth_manager.get_invite_code(invite.code.upper())
|
||||
|
||||
assert retrieved_lower is not None
|
||||
assert retrieved_upper is not None
|
||||
|
||||
def test_deactivate_invite_code(self, auth_manager):
|
||||
"""Can deactivate invite code."""
|
||||
admin = auth_manager.get_user_by_username("admin")
|
||||
invite = auth_manager.create_invite_code(created_by=admin.id)
|
||||
|
||||
auth_manager.deactivate_invite_code(invite.code)
|
||||
|
||||
updated = auth_manager.get_invite_code(invite.code)
|
||||
assert updated.is_valid() is False
|
||||
|
||||
|
||||
class TestAdminFunctions:
|
||||
"""Test admin-only functions."""
|
||||
|
||||
def test_list_users(self, auth_manager):
|
||||
"""Admin can list all users."""
|
||||
auth_manager.create_user(username="user1", password="pass1")
|
||||
auth_manager.create_user(username="user2", password="pass2")
|
||||
|
||||
users = auth_manager.list_users()
|
||||
|
||||
# Should include admin + 2 created users
|
||||
assert len(users) >= 3
|
||||
|
||||
def test_update_user_role(self, auth_manager):
|
||||
"""Admin can change user role."""
|
||||
user = auth_manager.create_user(username="testuser", password="pass")
|
||||
|
||||
updated = auth_manager.update_user(user.id, role=UserRole.ADMIN)
|
||||
|
||||
assert updated.is_admin() is True
|
||||
|
||||
def test_change_password(self, auth_manager):
|
||||
"""Admin can change user password."""
|
||||
user = auth_manager.create_user(username="testuser", password="oldpass")
|
||||
|
||||
auth_manager.change_password(user.id, "newpass")
|
||||
|
||||
# Old password should not work
|
||||
auth_fail = auth_manager.authenticate("testuser", "oldpass")
|
||||
assert auth_fail is None
|
||||
|
||||
# New password should work
|
||||
auth_ok = auth_manager.authenticate("testuser", "newpass")
|
||||
assert auth_ok is not None
|
||||
|
||||
def test_delete_user(self, auth_manager):
|
||||
"""Admin can deactivate user."""
|
||||
user = auth_manager.create_user(username="testuser", password="pass")
|
||||
|
||||
auth_manager.delete_user(user.id)
|
||||
|
||||
# User should be inactive
|
||||
updated = auth_manager.get_user_by_id(user.id)
|
||||
assert updated.is_active is False
|
||||
|
||||
# User should not be able to login
|
||||
auth_fail = auth_manager.authenticate("testuser", "pass")
|
||||
assert auth_fail is None
|
||||
|
||||
|
||||
class TestDefaultAdmin:
|
||||
"""Test default admin creation."""
|
||||
|
||||
def test_default_admin_created(self, auth_manager):
|
||||
"""Default admin is created if no admins exist."""
|
||||
admin = auth_manager.get_user_by_username("admin")
|
||||
|
||||
assert admin is not None
|
||||
assert admin.is_admin() is True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -148,15 +148,6 @@ class TestHouseRulesScoring:
|
||||
# K=-2, 3=3, columns 1&2 matched = 0
|
||||
assert score == 1
|
||||
|
||||
def test_lucky_sevens_zero(self):
|
||||
"""With lucky_sevens, 7s worth 0."""
|
||||
options = GameOptions(lucky_sevens=True)
|
||||
self.set_hand([Rank.SEVEN, Rank.ACE, Rank.ACE,
|
||||
Rank.THREE, Rank.ACE, Rank.ACE])
|
||||
score = self.player.calculate_score(options)
|
||||
# 7=0, 3=3, columns 1&2 matched = 0
|
||||
assert score == 3
|
||||
|
||||
def test_ten_penny(self):
|
||||
"""With ten_penny, 10s worth 1."""
|
||||
options = GameOptions(ten_penny=True)
|
||||
|
||||
@@ -315,5 +315,164 @@ class TestEdgeCases:
|
||||
)
|
||||
|
||||
|
||||
class TestAvoidBadPairs:
|
||||
"""Test that AI avoids creating wasteful pairs with negative cards."""
|
||||
|
||||
def test_filter_bad_pair_positions_with_visible_two(self):
|
||||
"""
|
||||
When placing a 2, avoid positions where column partner is a visible 2.
|
||||
|
||||
Setup: Visible 2 at position 0
|
||||
Placing: Another 2
|
||||
Expected: Position 3 should be filtered out (would pair with position 0)
|
||||
"""
|
||||
from ai import filter_bad_pair_positions
|
||||
|
||||
game = create_test_game()
|
||||
player = game.get_player("maya")
|
||||
|
||||
# Position 0 has a visible 2
|
||||
player.cards = [
|
||||
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 0: visible 2
|
||||
Card(Suit.HEARTS, Rank.FIVE, face_up=True), # Pos 1
|
||||
Card(Suit.HEARTS, Rank.SIX, face_up=True), # Pos 2
|
||||
Card(Suit.SPADES, Rank.SEVEN, face_up=False), # Pos 3: column partner of 0
|
||||
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 4
|
||||
Card(Suit.SPADES, Rank.NINE, face_up=False), # Pos 5
|
||||
]
|
||||
|
||||
drawn_two = Card(Suit.CLUBS, Rank.TWO)
|
||||
face_down = [3, 4, 5]
|
||||
|
||||
safe_positions = filter_bad_pair_positions(face_down, drawn_two, player, game.options)
|
||||
|
||||
# Position 3 should be filtered out (would pair with visible 2 at position 0)
|
||||
assert 3 not in safe_positions, (
|
||||
"Position 3 should be filtered - would create wasteful 2-2 pair"
|
||||
)
|
||||
assert 4 in safe_positions
|
||||
assert 5 in safe_positions
|
||||
|
||||
def test_filter_allows_positive_card_pairs(self):
|
||||
"""
|
||||
Positive value cards can be paired - no filtering needed.
|
||||
|
||||
Pairing a 5 with another 5 is GOOD (saves 10 points).
|
||||
"""
|
||||
from ai import filter_bad_pair_positions
|
||||
|
||||
game = create_test_game()
|
||||
player = game.get_player("maya")
|
||||
|
||||
# Position 0 has a visible 5
|
||||
player.cards = [
|
||||
Card(Suit.HEARTS, Rank.FIVE, face_up=True), # Pos 0: visible 5
|
||||
Card(Suit.HEARTS, Rank.SIX, face_up=True),
|
||||
Card(Suit.HEARTS, Rank.SEVEN, face_up=True),
|
||||
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 3: column partner
|
||||
Card(Suit.SPADES, Rank.NINE, face_up=False),
|
||||
Card(Suit.SPADES, Rank.TEN, face_up=False),
|
||||
]
|
||||
|
||||
drawn_five = Card(Suit.CLUBS, Rank.FIVE)
|
||||
face_down = [3, 4, 5]
|
||||
|
||||
safe_positions = filter_bad_pair_positions(face_down, drawn_five, player, game.options)
|
||||
|
||||
# All positions should be allowed - pairing 5s is good!
|
||||
assert safe_positions == face_down
|
||||
|
||||
def test_choose_swap_avoids_pairing_twos(self):
|
||||
"""
|
||||
The full choose_swap_or_discard flow should avoid placing 2s
|
||||
in positions that would pair them.
|
||||
|
||||
Run multiple times to verify randomness doesn't cause bad pairs.
|
||||
"""
|
||||
game = create_test_game()
|
||||
maya = game.get_player("maya")
|
||||
profile = get_maya_profile()
|
||||
|
||||
# Position 0 has a visible 2
|
||||
maya.cards = [
|
||||
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 0: visible 2
|
||||
Card(Suit.HEARTS, Rank.FIVE, face_up=True), # Pos 1
|
||||
Card(Suit.HEARTS, Rank.SIX, face_up=True), # Pos 2
|
||||
Card(Suit.SPADES, Rank.SEVEN, face_up=False), # Pos 3: BAD - would pair
|
||||
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 4: OK
|
||||
Card(Suit.SPADES, Rank.NINE, face_up=False), # Pos 5: OK
|
||||
]
|
||||
|
||||
drawn_two = Card(Suit.CLUBS, Rank.TWO)
|
||||
|
||||
# Run 100 times - should NEVER pick position 3
|
||||
bad_pair_count = 0
|
||||
for _ in range(100):
|
||||
swap_pos = GolfAI.choose_swap_or_discard(drawn_two, maya, profile, game)
|
||||
if swap_pos == 3:
|
||||
bad_pair_count += 1
|
||||
|
||||
assert bad_pair_count == 0, (
|
||||
f"AI picked position 3 (creating 2-2 pair) {bad_pair_count}/100 times. "
|
||||
"Should avoid positions that waste negative card value."
|
||||
)
|
||||
|
||||
def test_forced_swap_avoids_pairing_twos(self):
|
||||
"""
|
||||
Even when forced to swap from discard, AI should avoid bad pairs.
|
||||
"""
|
||||
from ai import filter_bad_pair_positions
|
||||
|
||||
game = create_test_game()
|
||||
player = game.get_player("maya")
|
||||
|
||||
# Position 1 has a visible 2, only positions 3, 4 are face-down
|
||||
player.cards = [
|
||||
Card(Suit.HEARTS, Rank.FIVE, face_up=True),
|
||||
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 1: visible 2
|
||||
Card(Suit.HEARTS, Rank.SIX, face_up=True),
|
||||
Card(Suit.SPADES, Rank.SEVEN, face_up=True),
|
||||
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 4: BAD - pairs with pos 1
|
||||
Card(Suit.SPADES, Rank.NINE, face_up=False), # Pos 5: OK
|
||||
]
|
||||
|
||||
drawn_two = Card(Suit.CLUBS, Rank.TWO)
|
||||
face_down = [4, 5]
|
||||
|
||||
safe_positions = filter_bad_pair_positions(face_down, drawn_two, player, game.options)
|
||||
|
||||
# Position 4 should be filtered out (would pair with visible 2 at position 1)
|
||||
assert 4 not in safe_positions
|
||||
assert 5 in safe_positions
|
||||
|
||||
def test_all_positions_bad_falls_back(self):
|
||||
"""
|
||||
If ALL positions would create bad pairs, fall back to original list.
|
||||
(Must place the card somewhere)
|
||||
"""
|
||||
from ai import filter_bad_pair_positions
|
||||
|
||||
game = create_test_game()
|
||||
player = game.get_player("maya")
|
||||
|
||||
# Only position 3 is face-down, and it would pair with visible 2 at position 0
|
||||
player.cards = [
|
||||
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 0: visible 2
|
||||
Card(Suit.HEARTS, Rank.FIVE, face_up=True),
|
||||
Card(Suit.HEARTS, Rank.SIX, face_up=True),
|
||||
Card(Suit.SPADES, Rank.SEVEN, face_up=False), # Pos 3: only option, but bad
|
||||
Card(Suit.SPADES, Rank.EIGHT, face_up=True),
|
||||
Card(Suit.SPADES, Rank.NINE, face_up=True),
|
||||
]
|
||||
|
||||
drawn_two = Card(Suit.CLUBS, Rank.TWO)
|
||||
face_down = [3] # Only option
|
||||
|
||||
safe_positions = filter_bad_pair_positions(face_down, drawn_two, player, game.options)
|
||||
|
||||
# Should return original list since there's no alternative
|
||||
assert safe_positions == face_down
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
Reference in New Issue
Block a user