Numerous WebUI animations, improvements, AI fixes, opporitunity cost-based decision logic, etc.

This commit is contained in:
Aaron D. Lee 2026-01-25 17:37:01 -05:00
parent d9073f862c
commit f80bab3b4b
35 changed files with 5772 additions and 403 deletions

86
.env.example Normal file
View File

@ -0,0 +1,86 @@
# =============================================================================
# Golf Game Server Configuration
# =============================================================================
# Copy this file to .env and customize as needed.
# All values shown are defaults.
# -----------------------------------------------------------------------------
# Server Settings
# -----------------------------------------------------------------------------
# Host to bind to (0.0.0.0 for all interfaces)
HOST=0.0.0.0
# Port to listen on
PORT=8000
# Enable debug mode (more verbose logging, auto-reload)
DEBUG=false
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL=INFO
# -----------------------------------------------------------------------------
# Database
# -----------------------------------------------------------------------------
# SQLite database for game logs and stats
# For PostgreSQL: postgresql://user:pass@host:5432/dbname
DATABASE_URL=sqlite:///games.db
# -----------------------------------------------------------------------------
# Room Settings
# -----------------------------------------------------------------------------
# Maximum players per game room
MAX_PLAYERS_PER_ROOM=6
# Room timeout in minutes (inactive rooms are cleaned up)
ROOM_TIMEOUT_MINUTES=60
# Length of room codes (e.g., 4 = "ABCD")
ROOM_CODE_LENGTH=4
# -----------------------------------------------------------------------------
# Security & Authentication (for future auth system)
# -----------------------------------------------------------------------------
# Secret key for JWT tokens (generate with: python -c "import secrets; print(secrets.token_hex(32))")
SECRET_KEY=
# Enable invite-only mode (requires invitation to register)
INVITE_ONLY=false
# Comma-separated list of admin email addresses
ADMIN_EMAILS=
# -----------------------------------------------------------------------------
# Game Defaults
# -----------------------------------------------------------------------------
# Default number of rounds (holes) per game
DEFAULT_ROUNDS=9
# Cards to flip at start of each round (0, 1, or 2)
DEFAULT_INITIAL_FLIPS=2
# Enable jokers in deck by default
DEFAULT_USE_JOKERS=false
# Require flipping a card after discarding from deck
DEFAULT_FLIP_ON_DISCARD=false
# -----------------------------------------------------------------------------
# Card Values (Standard 6-Card Golf)
# -----------------------------------------------------------------------------
# Customize point values for cards. Normally you shouldn't change these.
CARD_ACE=1
CARD_TWO=-2
CARD_KING=0
CARD_JOKER=-2
# House rule values
CARD_SUPER_KINGS=-2 # King value when super_kings enabled
CARD_TEN_PENNY=1 # 10 value when ten_penny enabled
CARD_LUCKY_SWING_JOKER=-5 # Joker value when lucky_swing enabled

247
bin/Activate.ps1 Normal file
View File

@ -0,0 +1,247 @@
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

76
bin/activate Normal file
View File

@ -0,0 +1,76 @@
# This file must be used with "source bin/activate" *from bash*
# You cannot run it directly
deactivate () {
# reset old environment variables
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
fi
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
export PYTHONHOME
unset _OLD_VIRTUAL_PYTHONHOME
fi
# This should detect bash and zsh, which have a hash command that must
# be called to get it to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
hash -r 2> /dev/null
fi
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}"
export PS1
unset _OLD_VIRTUAL_PS1
fi
unset VIRTUAL_ENV
unset VIRTUAL_ENV_PROMPT
if [ ! "${1:-}" = "nondestructive" ] ; then
# Self destruct!
unset -f deactivate
fi
}
# unset irrelevant variables
deactivate nondestructive
# on Windows, a path can contain colons and backslashes and has to be converted:
if [ "$OSTYPE" = "cygwin" ] || [ "$OSTYPE" = "msys" ] ; then
# transform D:\path\to\venv to /d/path/to/venv on MSYS
# and to /cygdrive/d/path/to/venv on Cygwin
export VIRTUAL_ENV=$(cygpath "/home/alee/Sources/golfgame")
else
# use the path as-is
export VIRTUAL_ENV="/home/alee/Sources/golfgame"
fi
_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/bin:$PATH"
export PATH
# unset PYTHONHOME if set
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
# could use `if (set -u; : $PYTHONHOME) ;` in bash
if [ -n "${PYTHONHOME:-}" ] ; then
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
unset PYTHONHOME
fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1:-}"
PS1="(golfgame) ${PS1:-}"
export PS1
VIRTUAL_ENV_PROMPT="(golfgame) "
export VIRTUAL_ENV_PROMPT
fi
# This should detect bash and zsh, which have a hash command that must
# be called to get it to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
hash -r 2> /dev/null
fi

27
bin/activate.csh Normal file
View File

@ -0,0 +1,27 @@
# This file must be used with "source bin/activate.csh" *from csh*.
# You cannot run it directly.
# Created by Davide Di Blasi <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
# Unset irrelevant variables.
deactivate nondestructive
setenv VIRTUAL_ENV "/home/alee/Sources/golfgame"
set _OLD_VIRTUAL_PATH="$PATH"
setenv PATH "$VIRTUAL_ENV/bin:$PATH"
set _OLD_VIRTUAL_PROMPT="$prompt"
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
set prompt = "(golfgame) $prompt"
setenv VIRTUAL_ENV_PROMPT "(golfgame) "
endif
alias pydoc python -m pydoc
rehash

69
bin/activate.fish Normal file
View File

@ -0,0 +1,69 @@
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/). You cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables
if test -n "$_OLD_VIRTUAL_PATH"
set -gx PATH $_OLD_VIRTUAL_PATH
set -e _OLD_VIRTUAL_PATH
end
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
set -e _OLD_VIRTUAL_PYTHONHOME
end
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
set -e _OLD_FISH_PROMPT_OVERRIDE
# prevents error when using nested fish instances (Issue #93858)
if functions -q _old_fish_prompt
functions -e fish_prompt
functions -c _old_fish_prompt fish_prompt
functions -e _old_fish_prompt
end
end
set -e VIRTUAL_ENV
set -e VIRTUAL_ENV_PROMPT
if test "$argv[1]" != "nondestructive"
# Self-destruct!
functions -e deactivate
end
end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV "/home/alee/Sources/golfgame"
set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/bin" $PATH
# Unset PYTHONHOME if set.
if set -q PYTHONHOME
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
set -e PYTHONHOME
end
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
# fish uses a function instead of an env var to generate the prompt.
# Save the current fish_prompt function as the function _old_fish_prompt.
functions -c fish_prompt _old_fish_prompt
# With the original prompt function renamed, we can override with our own.
function fish_prompt
# Save the return status of the last command.
set -l old_status $status
# Output the venv prompt; color taken from the blue of the Python logo.
printf "%s%s%s" (set_color 4B8BBE) "(golfgame) " (set_color normal)
# Restore the return status of the previous command.
echo "exit $old_status" | .
# Output the original/"old" prompt.
_old_fish_prompt
end
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
set -gx VIRTUAL_ENV_PROMPT "(golfgame) "
end

8
bin/pip Executable file
View File

@ -0,0 +1,8 @@
#!/home/alee/Sources/golfgame/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
bin/pip3 Executable file
View File

@ -0,0 +1,8 @@
#!/home/alee/Sources/golfgame/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
bin/pip3.12 Executable file
View File

@ -0,0 +1,8 @@
#!/home/alee/Sources/golfgame/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

1
bin/python Symbolic link
View File

@ -0,0 +1 @@
/home/alee/.pyenv/versions/3.12.0/bin/python

1
bin/python3 Symbolic link
View File

@ -0,0 +1 @@
python

1
bin/python3.12 Symbolic link
View File

@ -0,0 +1 @@
python

378
client/animation-queue.js Normal file
View File

@ -0,0 +1,378 @@
// AnimationQueue - Sequences card animations properly
// Ensures animations play in order without overlap
class AnimationQueue {
constructor(cardManager, getSlotRect, getLocationRect, playSound) {
this.cardManager = cardManager;
this.getSlotRect = getSlotRect; // Function to get slot position
this.getLocationRect = getLocationRect; // Function to get deck/discard position
this.playSound = playSound || (() => {}); // Sound callback
this.queue = [];
this.processing = false;
this.animationInProgress = false;
// Timing configuration (ms)
this.timing = {
flipDuration: 400,
moveDuration: 300,
pauseAfterMove: 200,
pauseAfterFlip: 100,
pauseBetweenAnimations: 100
};
}
// Add movements to the queue and start processing
async enqueue(movements, onComplete) {
if (!movements || movements.length === 0) {
if (onComplete) onComplete();
return;
}
// Add completion callback to last movement
const movementsWithCallback = movements.map((m, i) => ({
...m,
onComplete: i === movements.length - 1 ? onComplete : null
}));
this.queue.push(...movementsWithCallback);
if (!this.processing) {
await this.processQueue();
}
}
// Process queued animations one at a time
async processQueue() {
if (this.processing) return;
this.processing = true;
this.animationInProgress = true;
while (this.queue.length > 0) {
const movement = this.queue.shift();
try {
await this.animate(movement);
} catch (e) {
console.error('Animation error:', e);
}
// Callback after last movement
if (movement.onComplete) {
movement.onComplete();
}
// Pause between animations
if (this.queue.length > 0) {
await this.delay(this.timing.pauseBetweenAnimations);
}
}
this.processing = false;
this.animationInProgress = false;
}
// Route to appropriate animation
async animate(movement) {
switch (movement.type) {
case 'flip':
await this.animateFlip(movement);
break;
case 'swap':
await this.animateSwap(movement);
break;
case 'discard':
await this.animateDiscard(movement);
break;
case 'draw-deck':
await this.animateDrawDeck(movement);
break;
case 'draw-discard':
await this.animateDrawDiscard(movement);
break;
}
}
// Animate a card flip
async animateFlip(movement) {
const { playerId, position, faceUp, card } = movement;
// Get slot position
const slotRect = this.getSlotRect(playerId, position);
if (!slotRect || slotRect.width === 0 || slotRect.height === 0) {
return;
}
// Create animation card at slot position
const animCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard);
this.setCardPosition(animCard, slotRect);
const inner = animCard.querySelector('.card-inner');
const front = animCard.querySelector('.card-face-front');
// Set up what we're flipping to (front face)
this.setCardFront(front, card);
// Start face down (flipped = showing back)
inner.classList.add('flipped');
// Force a reflow to ensure the initial state is applied
animCard.offsetHeight;
// Animate the flip
this.playSound('flip');
await this.delay(50); // Brief pause before flip
// Remove flipped to trigger animation to front
inner.classList.remove('flipped');
await this.delay(this.timing.flipDuration);
await this.delay(this.timing.pauseAfterFlip);
// Clean up
animCard.remove();
}
// Animate a card swap (hand card to discard, drawn card to hand)
async animateSwap(movement) {
const { playerId, position, oldCard, newCard } = movement;
// Get positions
const slotRect = this.getSlotRect(playerId, position);
const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding');
if (!slotRect || !discardRect || slotRect.width === 0) {
return;
}
// Create a temporary card element for the animation
const animCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard);
// Position at slot
this.setCardPosition(animCard, slotRect);
// Start face down (showing back)
const inner = animCard.querySelector('.card-inner');
const front = animCard.querySelector('.card-face-front');
inner.classList.add('flipped');
// Step 1: If card was face down, flip to reveal it
if (!oldCard.face_up) {
// Set up the front with the old card content (what we're discarding)
this.setCardFront(front, oldCard);
this.playSound('flip');
inner.classList.remove('flipped');
await this.delay(this.timing.flipDuration);
} else {
// Already face up, just show it
this.setCardFront(front, oldCard);
inner.classList.remove('flipped');
}
await this.delay(100);
// Step 2: Move card to discard pile
this.playSound('card');
animCard.classList.add('moving');
this.setCardPosition(animCard, discardRect);
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
// Pause to show the card landing on discard
await this.delay(this.timing.pauseAfterMove + 200);
// Step 3: Create second card for the new card coming into hand
const newAnimCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(newAnimCard);
// New card starts at holding/discard position
this.setCardPosition(newAnimCard, holdingRect || discardRect);
const newInner = newAnimCard.querySelector('.card-inner');
const newFront = newAnimCard.querySelector('.card-face-front');
// Show new card (it's face up from the drawn card)
this.setCardFront(newFront, newCard);
newInner.classList.remove('flipped');
// Step 4: Move new card to the hand slot
this.playSound('card');
newAnimCard.classList.add('moving');
this.setCardPosition(newAnimCard, slotRect);
await this.delay(this.timing.moveDuration);
newAnimCard.classList.remove('moving');
// Clean up animation cards
await this.delay(this.timing.pauseAfterMove);
animCard.remove();
newAnimCard.remove();
}
// Create a temporary animation card element
createAnimCard() {
const card = document.createElement('div');
card.className = 'real-card anim-card';
card.innerHTML = `
<div class="card-inner">
<div class="card-face card-face-front"></div>
<div class="card-face card-face-back"><span>?</span></div>
</div>
`;
return card;
}
// Set card position
setCardPosition(card, rect) {
card.style.left = `${rect.left}px`;
card.style.top = `${rect.top}px`;
card.style.width = `${rect.width}px`;
card.style.height = `${rect.height}px`;
}
// Set card front content
setCardFront(frontEl, cardData) {
frontEl.className = 'card-face card-face-front';
if (!cardData) return;
if (cardData.rank === '★') {
frontEl.classList.add('joker');
const jokerIcon = cardData.suit === 'hearts' ? '🐉' : '👹';
frontEl.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = cardData.suit === 'hearts' || cardData.suit === 'diamonds';
frontEl.classList.add(isRed ? 'red' : 'black');
const suitSymbol = this.getSuitSymbol(cardData.suit);
frontEl.innerHTML = `${cardData.rank}<br>${suitSymbol}`;
}
}
getSuitSymbol(suit) {
const symbols = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' };
return symbols[suit] || '';
}
// Animate discarding a card (from hand to discard pile) - called for other players
async animateDiscard(movement) {
const { card, fromPlayerId, fromPosition } = movement;
// If no specific position, animate from opponent's area
const discardRect = this.getLocationRect('discard');
if (!discardRect) return;
let startRect;
if (fromPosition !== null && fromPosition !== undefined) {
startRect = this.getSlotRect(fromPlayerId, fromPosition);
}
// Fallback: use discard position offset upward
if (!startRect) {
startRect = {
left: discardRect.left,
top: discardRect.top - 80,
width: discardRect.width,
height: discardRect.height
};
}
// Create animation card
const animCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard);
this.setCardPosition(animCard, startRect);
const inner = animCard.querySelector('.card-inner');
const front = animCard.querySelector('.card-face-front');
// Show the card that was discarded
this.setCardFront(front, card);
inner.classList.remove('flipped');
// Move to discard
this.playSound('card');
animCard.classList.add('moving');
this.setCardPosition(animCard, discardRect);
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
await this.delay(this.timing.pauseAfterMove);
// Clean up
animCard.remove();
}
// Animate drawing from deck
async animateDrawDeck(movement) {
const { playerId } = movement;
const deckRect = this.getLocationRect('deck');
const holdingRect = this.getLocationRect('holding');
if (!deckRect || !holdingRect) return;
// Create animation card at deck position (face down)
const animCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard);
this.setCardPosition(animCard, deckRect);
const inner = animCard.querySelector('.card-inner');
inner.classList.add('flipped'); // Show back
// Move to holding position
this.playSound('card');
await this.delay(50);
animCard.classList.add('moving');
this.setCardPosition(animCard, holdingRect);
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
// The card stays face down until the player decides what to do
// (the actual card reveal happens when server sends card_drawn)
await this.delay(this.timing.pauseAfterMove);
// Clean up - renderGame will show the holding card state
animCard.remove();
}
// Animate drawing from discard
async animateDrawDiscard(movement) {
const { playerId } = movement;
// Discard to holding is mostly visual feedback
// The card "lifts" slightly
const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding');
if (!discardRect || !holdingRect) return;
// Just play sound - visual handled by CSS :holding state
this.playSound('card');
await this.delay(this.timing.moveDuration);
}
// Check if animations are currently playing
isAnimating() {
return this.animationInProgress;
}
// Clear the queue (for interruption)
clear() {
this.queue = [];
}
// Utility delay
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Export for use in app.js
if (typeof module !== 'undefined' && module.exports) {
module.exports = AnimationQueue;
}

File diff suppressed because it is too large Load Diff

259
client/card-manager.js Normal file
View File

@ -0,0 +1,259 @@
// CardManager - Manages persistent card DOM elements
// Cards are REAL elements that exist in ONE place and move between locations
class CardManager {
constructor(cardLayer) {
this.cardLayer = cardLayer;
// Map of "playerId-position" -> card element
this.handCards = new Map();
// Special cards
this.deckCard = null;
this.discardCard = null;
this.holdingCard = null;
}
// Initialize cards for a game state
initializeCards(gameState, playerId, getSlotRect, getDeckRect, getDiscardRect) {
this.clear();
// Create cards for each player's hand
for (const player of gameState.players) {
for (let i = 0; i < 6; i++) {
const card = player.cards[i];
const slotKey = `${player.id}-${i}`;
const cardEl = this.createCardElement(card);
// Position at slot (will be updated later if rect not ready)
const rect = getSlotRect(player.id, i);
if (rect && rect.width > 0) {
this.positionCard(cardEl, rect);
} else {
// Start invisible, will be positioned by updateAllPositions
cardEl.style.opacity = '0';
}
this.handCards.set(slotKey, {
element: cardEl,
cardData: card,
playerId: player.id,
position: i
});
this.cardLayer.appendChild(cardEl);
}
}
}
// Create a card DOM element with 3D flip structure
createCardElement(cardData) {
const card = document.createElement('div');
card.className = 'real-card';
card.innerHTML = `
<div class="card-inner">
<div class="card-face card-face-front"></div>
<div class="card-face card-face-back"><span>?</span></div>
</div>
`;
this.updateCardAppearance(card, cardData);
return card;
}
// Update card visual state (face up/down, content)
updateCardAppearance(cardEl, cardData) {
const inner = cardEl.querySelector('.card-inner');
const front = cardEl.querySelector('.card-face-front');
// Reset front classes
front.className = 'card-face card-face-front';
if (!cardData || !cardData.face_up || !cardData.rank) {
// Face down or no data
inner.classList.add('flipped');
front.innerHTML = '';
} else {
// Face up with data
inner.classList.remove('flipped');
if (cardData.rank === '★') {
front.classList.add('joker');
const icon = cardData.suit === 'hearts' ? '🐉' : '👹';
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = cardData.suit === 'hearts' || cardData.suit === 'diamonds';
front.classList.add(isRed ? 'red' : 'black');
front.innerHTML = `${cardData.rank}<br>${this.getSuitSymbol(cardData.suit)}`;
}
}
}
getSuitSymbol(suit) {
return { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || '';
}
// Position a card at a rect
positionCard(cardEl, rect, animate = false) {
if (animate) {
cardEl.classList.add('moving');
}
cardEl.style.left = `${rect.left}px`;
cardEl.style.top = `${rect.top}px`;
cardEl.style.width = `${rect.width}px`;
cardEl.style.height = `${rect.height}px`;
if (animate) {
setTimeout(() => cardEl.classList.remove('moving'), 350);
}
}
// Get a hand card by player and position
getHandCard(playerId, position) {
return this.handCards.get(`${playerId}-${position}`);
}
// Update all card positions to match current slot positions
// Returns number of cards successfully positioned
updateAllPositions(getSlotRect) {
let positioned = 0;
for (const [key, cardInfo] of this.handCards) {
const rect = getSlotRect(cardInfo.playerId, cardInfo.position);
if (rect && rect.width > 0) {
this.positionCard(cardInfo.element, rect, false);
// Restore visibility if it was hidden
cardInfo.element.style.opacity = '1';
positioned++;
}
}
return positioned;
}
// Animate a card flip
async flipCard(playerId, position, newCardData, duration = 400) {
const cardInfo = this.getHandCard(playerId, position);
if (!cardInfo) return;
const inner = cardInfo.element.querySelector('.card-inner');
const front = cardInfo.element.querySelector('.card-face-front');
// Set up the front content before flip
front.className = 'card-face card-face-front';
if (newCardData.rank === '★') {
front.classList.add('joker');
const icon = newCardData.suit === 'hearts' ? '🐉' : '👹';
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = newCardData.suit === 'hearts' || newCardData.suit === 'diamonds';
front.classList.add(isRed ? 'red' : 'black');
front.innerHTML = `${newCardData.rank}<br>${this.getSuitSymbol(newCardData.suit)}`;
}
// Animate flip
inner.classList.remove('flipped');
await this.delay(duration);
cardInfo.cardData = newCardData;
}
// Animate a swap: hand card goes to discard, new card comes to hand
async animateSwap(playerId, position, oldCardData, newCardData, getSlotRect, getDiscardRect, duration = 300) {
const cardInfo = this.getHandCard(playerId, position);
if (!cardInfo) return;
const slotRect = getSlotRect(playerId, position);
const discardRect = getDiscardRect();
if (!slotRect || !discardRect) return;
if (!oldCardData || !oldCardData.rank) {
// Can't animate without card data - just update appearance
this.updateCardAppearance(cardInfo.element, newCardData);
cardInfo.cardData = newCardData;
return;
}
const cardEl = cardInfo.element;
const inner = cardEl.querySelector('.card-inner');
const front = cardEl.querySelector('.card-face-front');
// Step 1: If face down, flip to reveal the old card
if (!oldCardData.face_up) {
// Set front to show old card
front.className = 'card-face card-face-front';
if (oldCardData.rank === '★') {
front.classList.add('joker');
const icon = oldCardData.suit === 'hearts' ? '🐉' : '👹';
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = oldCardData.suit === 'hearts' || oldCardData.suit === 'diamonds';
front.classList.add(isRed ? 'red' : 'black');
front.innerHTML = `${oldCardData.rank}<br>${this.getSuitSymbol(oldCardData.suit)}`;
}
inner.classList.remove('flipped');
await this.delay(400);
}
// Step 2: Move card to discard
cardEl.classList.add('moving');
this.positionCard(cardEl, discardRect);
await this.delay(duration + 50);
cardEl.classList.remove('moving');
// Pause to show the discarded card
await this.delay(250);
// Step 3: Update card to show new card and move back to hand
front.className = 'card-face card-face-front';
if (newCardData.rank === '★') {
front.classList.add('joker');
const icon = newCardData.suit === 'hearts' ? '🐉' : '👹';
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = newCardData.suit === 'hearts' || newCardData.suit === 'diamonds';
front.classList.add(isRed ? 'red' : 'black');
front.innerHTML = `${newCardData.rank}<br>${this.getSuitSymbol(newCardData.suit)}`;
}
if (!newCardData.face_up) {
inner.classList.add('flipped');
}
cardEl.classList.add('moving');
this.positionCard(cardEl, slotRect);
await this.delay(duration + 50);
cardEl.classList.remove('moving');
cardInfo.cardData = newCardData;
}
// Set holding state for a card (drawn card highlight)
setHolding(playerId, position, isHolding) {
const cardInfo = this.getHandCard(playerId, position);
if (cardInfo) {
cardInfo.element.classList.toggle('holding', isHolding);
}
}
// Clear all cards
clear() {
for (const [key, cardInfo] of this.handCards) {
cardInfo.element.remove();
}
this.handCards.clear();
if (this.holdingCard) {
this.holdingCard.remove();
this.holdingCard = null;
}
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = CardManager;
}

View File

@ -200,19 +200,24 @@
<!-- Game Screen -->
<div id="game-screen" class="screen">
<!-- Card layer for persistent card elements -->
<div id="card-layer"></div>
<div class="game-layout">
<div class="game-main">
<div class="game-header">
<div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div>
<div id="active-rules-bar" class="active-rules-bar hidden">
<span class="rules-label">Rules:</span>
<span id="active-rules-list" class="rules-list"></span>
<div class="game-header-center">
<div id="active-rules-bar" class="active-rules-bar hidden">
<span class="rules-label">Rules:</span>
<span id="active-rules-list" class="rules-list"></span>
</div>
<div class="header-status">
<div id="status-message" class="status-message"></div>
</div>
</div>
<div class="turn-info" id="turn-info">Your turn</div>
<div class="score-info">Showing: <span id="your-score">0</span></div>
<div class="header-buttons">
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
</div>
</div>
@ -225,22 +230,30 @@
<div id="deck" class="card card-back">
<span>?</span>
</div>
<div id="discard" class="card">
<span id="discard-content"></span>
<div class="discard-stack">
<div id="discard" class="card">
<span id="discard-content"></span>
</div>
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
</div>
</div>
<div id="drawn-card-area" class="hidden">
<div id="drawn-card" class="card"></div>
<button id="discard-btn" class="btn btn-small">Discard</button>
</div>
</div>
<div class="player-section">
<div id="flip-prompt" class="flip-prompt hidden"></div>
<div class="player-area">
<h4 id="player-header">You<span id="your-score" class="player-showing">0</span></h4>
<div id="player-cards" class="card-grid"></div>
</div>
<div id="toast" class="toast hidden"></div>
</div>
<!-- Legacy swap animation overlay (kept for rollback) -->
<div id="swap-animation" class="swap-animation hidden">
<div id="swap-card-from-hand" class="swap-card">
<div class="swap-card-inner">
<div class="swap-card-front"></div>
<div class="swap-card-back">?</div>
</div>
</div>
</div>
</div>
</div>
@ -287,6 +300,9 @@
</div>
</div>
<script src="card-manager.js"></script>
<script src="state-differ.js"></script>
<script src="animation-queue.js"></script>
<script src="app.js"></script>
</body>
</html>

164
client/state-differ.js Normal file
View File

@ -0,0 +1,164 @@
// StateDiffer - Detects what changed between game states
// Generates movement instructions for the animation queue
class StateDiffer {
constructor() {
this.previousState = null;
}
// Compare old and new state, return array of movements
diff(oldState, newState) {
const movements = [];
if (!oldState || !newState) {
return movements;
}
// Check for initial flip phase - still animate initial flips
if (oldState.waiting_for_initial_flip && !newState.waiting_for_initial_flip) {
// Initial flip just completed - detect which cards were flipped
for (const newPlayer of newState.players) {
const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
if (oldPlayer) {
for (let i = 0; i < 6; i++) {
if (!oldPlayer.cards[i].face_up && newPlayer.cards[i].face_up) {
movements.push({
type: 'flip',
playerId: newPlayer.id,
position: i,
faceUp: true,
card: newPlayer.cards[i]
});
}
}
}
}
return movements;
}
// Still in initial flip selection - no animations
if (newState.waiting_for_initial_flip) {
return movements;
}
// Check for turn change - the previous player just acted
const previousPlayerId = oldState.current_player_id;
const currentPlayerId = newState.current_player_id;
const turnChanged = previousPlayerId !== currentPlayerId;
// Detect if a swap happened (discard changed AND a hand position changed)
const newTop = newState.discard_top;
const oldTop = oldState.discard_top;
const discardChanged = newTop && (!oldTop ||
oldTop.rank !== newTop.rank ||
oldTop.suit !== newTop.suit);
// Find hand changes for the player who just played
if (turnChanged && previousPlayerId) {
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
if (oldPlayer && newPlayer) {
// First pass: detect swaps (card identity changed)
const swappedPositions = new Set();
for (let i = 0; i < 6; i++) {
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
// Card identity changed = swap happened at this position
if (this.cardIdentityChanged(oldCard, newCard)) {
swappedPositions.add(i);
// Use discard_top for the revealed card (more reliable for opponents)
const revealedCard = newState.discard_top || { ...oldCard, face_up: true };
movements.push({
type: 'swap',
playerId: previousPlayerId,
position: i,
oldCard: revealedCard,
newCard: newCard
});
break; // Only one swap per turn
}
}
// Second pass: detect flips (card went from face_down to face_up, not a swap)
for (let i = 0; i < 6; i++) {
if (swappedPositions.has(i)) continue; // Skip if already detected as swap
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
if (this.cardWasFlipped(oldCard, newCard)) {
movements.push({
type: 'flip',
playerId: previousPlayerId,
position: i,
faceUp: true,
card: newCard
});
}
}
}
}
// Detect drawing (current player just drew)
if (newState.has_drawn_card && !oldState.has_drawn_card) {
// Discard pile decreased = drew from discard
const drewFromDiscard = !newState.discard_top ||
(oldState.discard_top &&
(!newState.discard_top ||
oldState.discard_top.rank !== newState.discard_top.rank ||
oldState.discard_top.suit !== newState.discard_top.suit));
movements.push({
type: drewFromDiscard ? 'draw-discard' : 'draw-deck',
playerId: currentPlayerId
});
}
return movements;
}
// Check if the card identity (rank+suit) changed between old and new
// Returns true if definitely different cards, false if same or unknown
cardIdentityChanged(oldCard, newCard) {
// If both have rank/suit data, compare directly
if (oldCard.rank && newCard.rank) {
return oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit;
}
// Can't determine - assume same card (flip, not swap)
return false;
}
// Check if a card was just flipped (same card, now face up)
cardWasFlipped(oldCard, newCard) {
return !oldCard.face_up && newCard.face_up;
}
// Get a summary of movements for debugging
summarize(movements) {
return movements.map(m => {
switch (m.type) {
case 'flip':
return `Flip: Player ${m.playerId} position ${m.position}`;
case 'swap':
return `Swap: Player ${m.playerId} position ${m.position}`;
case 'discard':
return `Discard: ${m.card.rank}${m.card.suit} from player ${m.fromPlayerId}`;
case 'draw-deck':
return `Draw from deck: Player ${m.playerId}`;
case 'draw-discard':
return `Draw from discard: Player ${m.playerId}`;
default:
return `Unknown: ${m.type}`;
}
}).join('\n');
}
}
// Export for use in app.js
if (typeof module !== 'undefined' && module.exports) {
module.exports = StateDiffer;
}

View File

@ -462,13 +462,11 @@ input::placeholder {
/* Game Screen */
.game-header {
display: grid;
grid-template-columns: auto 1fr auto auto auto;
display: flex;
align-items: center;
gap: 15px;
padding: 10px 25px;
justify-content: space-between;
padding: 10px 20px;
background: rgba(0,0,0,0.35);
border-radius: 0;
font-size: 0.9rem;
width: 100vw;
margin-left: calc(-50vw + 50%);
@ -480,20 +478,22 @@ input::placeholder {
white-space: nowrap;
}
.game-header-center {
display: flex;
align-items: center;
gap: 40px;
}
.game-header .turn-info {
font-weight: 600;
color: #f4a460;
white-space: nowrap;
}
.game-header .score-info {
white-space: nowrap;
}
.game-header .header-buttons {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
}
#leave-game-btn {
@ -602,8 +602,24 @@ input::placeholder {
}
.card-front.joker {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.card-front.joker .joker-icon {
font-size: 1.6em;
line-height: 1;
}
.card-front.joker .joker-label {
font-size: 0.45em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9b59b6;
font-size: 1.1rem;
}
.card.clickable {
@ -762,6 +778,25 @@ input::placeholder {
border: 2px solid #ddd;
}
/* Holding state - when player has drawn a card */
#discard.holding {
background: #fff;
border: 3px solid #f4a460;
box-shadow: 0 0 15px rgba(244, 164, 96, 0.6);
transform: scale(1.05);
}
.discard-stack {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.discard-stack .btn {
white-space: nowrap;
}
#deck.disabled,
#discard.disabled {
opacity: 0.5;
@ -777,45 +812,140 @@ input::placeholder {
/* Card flip animation for discard pile */
.card-flip-in {
animation: cardFlipIn 0.4s ease-out;
animation: cardFlipIn 0.5s ease-out;
}
@keyframes cardFlipIn {
0% {
transform: scale(1.3) rotateY(90deg);
opacity: 0.5;
box-shadow: 0 0 30px rgba(244, 164, 96, 0.8);
transform: scale(1.4) translateY(-20px);
opacity: 0;
box-shadow: 0 0 40px rgba(244, 164, 96, 1);
}
50% {
transform: scale(1.15) rotateY(0deg);
30% {
transform: scale(1.25) translateY(-10px);
opacity: 1;
box-shadow: 0 0 25px rgba(244, 164, 96, 0.6);
box-shadow: 0 0 35px rgba(244, 164, 96, 0.9);
}
70% {
transform: scale(1.1) translateY(0);
box-shadow: 0 0 20px rgba(244, 164, 96, 0.5);
}
100% {
transform: scale(1) rotateY(0deg);
transform: scale(1) translateY(0);
opacity: 1;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
}
#drawn-card-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: rgba(0,0,0,0.25);
/* Swap animation overlay */
.swap-animation {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1000;
perspective: 1000px;
}
.swap-animation.hidden {
display: none;
}
.swap-card {
position: absolute;
width: 70px;
height: 98px;
perspective: 1000px;
}
.swap-card-inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.4s ease-in-out;
}
.swap-card.flipping .swap-card-inner {
transform: rotateY(180deg);
}
.swap-card-front,
.swap-card-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
box-shadow: 0 4px 15px rgba(0,0,0,0.4);
}
#drawn-card-area .card {
width: clamp(80px, 7vw, 120px);
height: clamp(112px, 9.8vw, 168px);
font-size: clamp(2.4rem, 3.2vw, 4rem);
.swap-card-back {
background: linear-gradient(135deg, #c0392b 0%, #922b21 100%);
color: rgba(255,255,255,0.4);
font-size: 2rem;
}
#drawn-card-area .btn {
white-space: nowrap;
.swap-card-front {
background: linear-gradient(145deg, #fff 0%, #f0f0f0 100%);
transform: rotateY(180deg);
font-size: 2rem;
flex-direction: column;
color: #2c3e50;
line-height: 1.1;
font-weight: bold;
}
.swap-card-front.red {
color: #e74c3c;
}
.swap-card-front.black {
color: #2c3e50;
}
.swap-card-front.joker {
color: #9b59b6;
}
.swap-card-front .joker-icon {
font-size: 1.6em;
line-height: 1;
}
.swap-card-front .joker-label {
font-size: 0.45em;
font-weight: 700;
text-transform: uppercase;
}
/* Movement animation */
.swap-card.flipping {
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
}
.swap-card.moving {
transition: top 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s ease-out;
transform: scale(1.1) rotate(-5deg);
filter: drop-shadow(0 0 25px rgba(244, 164, 96, 1));
}
/* Card in hand fading during swap */
.card.swap-out {
opacity: 0;
transition: opacity 0.1s;
}
/* Discard fading during swap */
#discard.swap-to-hand {
opacity: 0;
transition: opacity 0.2s;
}
/* Player Area */
@ -856,7 +986,8 @@ input::placeholder {
align-items: center;
}
.opponent-showing {
.opponent-showing,
.player-showing {
font-weight: 700;
color: rgba(255, 255, 255, 0.9);
background: rgba(0, 0, 0, 0.25);
@ -866,6 +997,18 @@ input::placeholder {
margin-left: 8px;
}
/* Player area header - matches opponent style */
.player-area h4 {
font-size: clamp(0.8rem, 1vw, 1.1rem);
margin: 0 0 8px 0;
padding: clamp(4px, 0.4vw, 8px) clamp(10px, 1vw, 16px);
background: rgba(244, 164, 96, 0.6);
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.opponent-area .card-grid {
display: grid;
grid-template-columns: repeat(3, clamp(45px, 4vw, 75px));
@ -885,25 +1028,27 @@ input::placeholder {
}
/* Toast Notification */
.toast {
background: rgba(0, 0, 0, 0.9);
color: #fff;
padding: 8px 20px;
border-radius: 6px;
font-size: 0.85rem;
/* Header status area */
.header-status {
display: flex;
align-items: center;
justify-content: center;
min-width: 200px;
}
.status-message {
padding: 6px 16px;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 600;
text-align: center;
margin-top: 8px;
animation: toastIn 0.3s ease;
white-space: nowrap;
color: #fff;
}
.toast.hidden {
display: none;
}
.toast.your-turn {
.status-message.your-turn {
background: linear-gradient(135deg, #f4a460 0%, #e8914d 100%);
color: #1a472a;
font-weight: 600;
}
@keyframes toastIn {
@ -915,12 +1060,12 @@ input::placeholder {
.flip-prompt {
background: linear-gradient(135deg, #f4a460 0%, #e8914d 100%);
color: #1a472a;
padding: 8px 16px;
border-radius: 6px;
font-size: 0.9rem;
padding: 6px 16px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
text-align: center;
margin-bottom: 8px;
white-space: nowrap;
}
.flip-prompt.hidden {
@ -955,44 +1100,40 @@ input::placeholder {
/* Side Panels - positioned in bottom corners */
.side-panel {
position: fixed;
bottom: 20px;
bottom: 15px;
background: linear-gradient(145deg, rgba(15, 50, 35, 0.92) 0%, rgba(8, 30, 20, 0.95) 100%);
border-radius: 16px;
padding: 18px 20px;
width: 263px;
border-radius: 10px;
padding: 10px 12px;
width: 200px;
z-index: 100;
backdrop-filter: blur(10px);
border: 1px solid rgba(244, 164, 96, 0.25);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.5),
0 0 40px rgba(244, 164, 96, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.side-panel.left-panel {
left: 20px;
left: 15px;
}
.side-panel.right-panel {
right: 20px;
right: 15px;
}
.side-panel > h4 {
font-size: 1rem;
font-size: 0.7rem;
text-align: center;
margin-bottom: 14px;
margin-bottom: 8px;
color: #f4a460;
text-transform: uppercase;
letter-spacing: 0.2em;
letter-spacing: 0.15em;
font-weight: 700;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
border-bottom: 1px solid rgba(244, 164, 96, 0.2);
padding-bottom: 12px;
padding-bottom: 6px;
}
/* Standings list - two sections, top 4 each */
.standings-section {
margin-bottom: 10px;
margin-bottom: 6px;
}
.standings-section:last-child {
@ -1000,27 +1141,27 @@ input::placeholder {
}
.standings-title {
font-size: 0.7rem;
font-size: 0.6rem;
color: rgba(255,255,255,0.5);
text-transform: uppercase;
letter-spacing: 0.1em;
padding-bottom: 3px;
margin-bottom: 3px;
padding-bottom: 2px;
margin-bottom: 2px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.standings-list .rank-row {
display: grid;
grid-template-columns: 22px 1fr 36px;
gap: 4px;
font-size: 0.8rem;
padding: 2px 0;
grid-template-columns: 18px 1fr 30px;
gap: 3px;
font-size: 0.7rem;
padding: 1px 0;
align-items: center;
}
.standings-list .rank-pos {
text-align: center;
font-size: 0.75rem;
font-size: 0.65rem;
}
.standings-list .rank-name {
@ -1031,7 +1172,7 @@ input::placeholder {
.standings-list .rank-val {
text-align: right;
font-size: 0.75rem;
font-size: 0.65rem;
color: rgba(255,255,255,0.7);
}
@ -1047,12 +1188,12 @@ input::placeholder {
.side-panel table {
width: 100%;
border-collapse: collapse;
font-size: 1rem;
font-size: 0.75rem;
}
.side-panel th,
.side-panel td {
padding: 8px 6px;
padding: 4px 3px;
text-align: center;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
@ -1060,9 +1201,9 @@ input::placeholder {
.side-panel th {
font-weight: 600;
background: rgba(0,0,0,0.25);
font-size: 0.85rem;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0.03em;
color: rgba(255, 255, 255, 0.6);
}
@ -1081,15 +1222,15 @@ input::placeholder {
}
.game-buttons {
margin-top: 12px;
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 5px;
}
.game-buttons .btn {
font-size: 0.8rem;
padding: 10px 12px;
font-size: 0.7rem;
padding: 6px 8px;
width: 100%;
}
@ -1347,6 +1488,181 @@ input::placeholder {
.suit-clubs::after { content: "♣"; }
.suit-spades::after { content: "♠"; }
/* ============================================
New Card System - Persistent Card Elements
============================================ */
/* Card Layer - container for all persistent cards */
#card-layer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 500;
perspective: 1000px;
}
#card-layer .real-card {
pointer-events: auto;
}
/* Card Slot - only used when USE_NEW_CARD_SYSTEM is true */
.card-slot {
display: none;
}
/* Real Card - persistent card element with 3D structure */
.real-card {
position: fixed;
border-radius: 6px;
perspective: 1000px;
z-index: 501;
cursor: pointer;
transition: box-shadow 0.2s, opacity 0.2s;
}
.real-card:hover {
z-index: 510;
}
.real-card .card-inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.4s ease-in-out;
}
.real-card .card-inner.flipped {
transform: rotateY(180deg);
}
.real-card .card-face {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-weight: bold;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
/* Card Front */
.real-card .card-face-front {
background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%);
border: 2px solid #ddd;
color: #333;
font-size: clamp(1.8rem, 2.2vw, 2.8rem);
line-height: 1.1;
}
.real-card .card-face-front.red {
color: #c0392b;
}
.real-card .card-face-front.black {
color: #2c3e50;
}
.real-card .card-face-front.joker {
color: #9b59b6;
}
.real-card .card-face-front .joker-icon {
font-size: 1.5em;
line-height: 1;
}
.real-card .card-face-front .joker-label {
font-size: 0.4em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Card Back */
.real-card .card-face-back {
background-color: #c41e3a;
background-image:
linear-gradient(45deg, rgba(255,255,255,0.25) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255,255,255,0.25) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.25) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.25) 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0;
border: 3px solid #8b1528;
color: rgba(255, 255, 255, 0.5);
font-size: clamp(1.8rem, 2.5vw, 3rem);
transform: rotateY(180deg);
}
/* Card States */
.real-card.moving,
.real-card.anim-card.moving {
z-index: 600;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
top 0.3s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.3s ease-out;
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
transform: scale(1.08) rotate(-3deg);
}
/* Animation card - temporary cards used for animations */
.real-card.anim-card {
z-index: 700;
pointer-events: none;
}
.real-card.anim-card .card-inner {
transition: transform 0.4s ease-in-out;
}
.real-card.holding {
z-index: 550;
box-shadow: 0 0 20px rgba(244, 164, 96, 0.6),
0 4px 15px rgba(0, 0, 0, 0.4);
transform: scale(1.08);
}
.real-card.clickable {
box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5);
}
.real-card.clickable:hover {
box-shadow: 0 0 0 3px #f4a460,
0 4px 12px rgba(0, 0, 0, 0.3);
transform: scale(1.02);
}
.real-card.selected {
box-shadow: 0 0 0 4px #fff, 0 0 15px 5px #f4a460;
transform: scale(1.06);
z-index: 520;
}
.real-card.drawing {
z-index: 590;
}
/* Deck card styling in new system */
#deck.new-system {
cursor: pointer;
position: relative;
}
/* Discard styling for new system */
#discard.new-system.holding {
box-shadow: none;
background: rgba(255, 255, 255, 0.1);
border: 2px dashed rgba(244, 164, 96, 0.5);
}
/* Modal */
.modal {
position: fixed;

1
lib64 Symbolic link
View File

@ -0,0 +1 @@
lib

115
pyproject.toml Normal file
View File

@ -0,0 +1,115 @@
[project]
name = "golfgame"
version = "0.1.0"
description = "6-Card Golf card game with AI opponents"
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
authors = [
{name = "alee"}
]
keywords = ["card-game", "golf", "websocket", "fastapi", "ai"]
classifiers = [
"Development Status :: 3 - Alpha",
"Framework :: FastAPI",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Games/Entertainment :: Board Games",
]
dependencies = [
"fastapi>=0.109.0",
"uvicorn[standard]>=0.27.0",
"websockets>=12.0",
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.1.0",
"ruff>=0.1.0",
"mypy>=1.8.0",
]
[project.scripts]
golfgame = "server.main:run"
[project.urls]
Homepage = "https://github.com/alee/golfgame"
Repository = "https://github.com/alee/golfgame"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["server"]
# ============================================================================
# Tool Configuration
# ============================================================================
[tool.pytest.ini_options]
testpaths = ["server"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
asyncio_mode = "auto"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = [
"E501", # line too long (handled by formatter)
"B008", # do not perform function calls in argument defaults
]
[tool.ruff.lint.isort]
known-first-party = ["server"]
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_ignores = true
disallow_untyped_defs = true
ignore_missing_imports = true
# ============================================================================
# Game Configuration Defaults
# ============================================================================
# These can be overridden via environment variables
# See .env.example for documentation
[tool.golfgame]
# Server settings
host = "0.0.0.0"
port = 8000
debug = false
log_level = "INFO"
# Database
database_url = "sqlite:///server/games.db"
# Game defaults
default_rounds = 9
max_players_per_room = 6
room_timeout_minutes = 60
# Card values (standard 6-Card Golf)
# These are defined in server/constants.py

5
pyvenv.cfg Normal file
View File

@ -0,0 +1,5 @@
home = /home/alee/.pyenv/versions/3.12.0/bin
include-system-site-packages = false
version = 3.12.0
executable = /home/alee/.pyenv/versions/3.12.0/bin/python3.12
command = /home/alee/.pyenv/versions/3.12.0/bin/python -m venv /home/alee/Sources/golfgame

View File

@ -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*

View File

@ -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
View 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
View 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

View File

@ -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,

View File

@ -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

Binary file not shown.

View File

@ -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()

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@ -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)

View File

@ -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
View 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"])

View File

@ -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)

View File

@ -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"])