diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..94ce0af --- /dev/null +++ b/.env.example @@ -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 diff --git a/bin/Activate.ps1 b/bin/Activate.ps1 new file mode 100644 index 0000000..eeea358 --- /dev/null +++ b/bin/Activate.ps1 @@ -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" diff --git a/bin/activate b/bin/activate new file mode 100644 index 0000000..5e7044b --- /dev/null +++ b/bin/activate @@ -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 diff --git a/bin/activate.csh b/bin/activate.csh new file mode 100644 index 0000000..6fd86f2 --- /dev/null +++ b/bin/activate.csh @@ -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 . +# Ported to Python 3.3 venv by Andrew Svetlov + +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 diff --git a/bin/activate.fish b/bin/activate.fish new file mode 100644 index 0000000..c345be7 --- /dev/null +++ b/bin/activate.fish @@ -0,0 +1,69 @@ +# This file must be used with "source /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 diff --git a/bin/pip b/bin/pip new file mode 100755 index 0000000..6c31182 --- /dev/null +++ b/bin/pip @@ -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()) diff --git a/bin/pip3 b/bin/pip3 new file mode 100755 index 0000000..6c31182 --- /dev/null +++ b/bin/pip3 @@ -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()) diff --git a/bin/pip3.12 b/bin/pip3.12 new file mode 100755 index 0000000..6c31182 --- /dev/null +++ b/bin/pip3.12 @@ -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()) diff --git a/bin/python b/bin/python new file mode 120000 index 0000000..3078fe8 --- /dev/null +++ b/bin/python @@ -0,0 +1 @@ +/home/alee/.pyenv/versions/3.12.0/bin/python \ No newline at end of file diff --git a/bin/python3 b/bin/python3 new file mode 120000 index 0000000..d8654aa --- /dev/null +++ b/bin/python3 @@ -0,0 +1 @@ +python \ No newline at end of file diff --git a/bin/python3.12 b/bin/python3.12 new file mode 120000 index 0000000..d8654aa --- /dev/null +++ b/bin/python3.12 @@ -0,0 +1 @@ +python \ No newline at end of file diff --git a/client/animation-queue.js b/client/animation-queue.js new file mode 100644 index 0000000..176fd28 --- /dev/null +++ b/client/animation-queue.js @@ -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 = ` +
+
+
?
+
+ `; + 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 = `${jokerIcon}Joker`; + } 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}
${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; +} diff --git a/client/app.js b/client/app.js index 523d1ff..7251def 100644 --- a/client/app.js +++ b/client/app.js @@ -1,5 +1,9 @@ // Golf Card Game - Client Application +// Feature flag for new persistent card system +// Disabled - using improved legacy system instead +const USE_NEW_CARD_SYSTEM = false; + class GolfGame { constructor() { this.ws = null; @@ -15,9 +19,37 @@ class GolfGame { this.soundEnabled = true; this.audioCtx = null; + // Swap animation state (legacy) + this.swapAnimationInProgress = false; + this.swapAnimationCardEl = null; + this.swapAnimationFront = null; + this.pendingGameState = null; + + // New card system state + this.previousState = null; + this.isAnimating = false; + + // Track cards we've locally flipped (for immediate feedback during selection) + this.locallyFlippedCards = new Set(); + + // Animation lock - prevent overlapping animations on same elements + this.animatingPositions = new Set(); + this.initElements(); this.initAudio(); this.bindEvents(); + + // Initialize new card system + if (USE_NEW_CARD_SYSTEM) { + this.initNewCardSystem(); + + // Update card positions on resize + window.addEventListener('resize', () => { + if (this.cardManager && this.gameState) { + this.cardManager.updateAllPositions((pid, pos) => this.getSlotRect(pid, pos)); + } + }); + } } initAudio() { @@ -96,6 +128,167 @@ class GolfGame { this.playSound('click'); } + initNewCardSystem() { + const cardLayer = document.getElementById('card-layer'); + this.cardManager = new CardManager(cardLayer); + this.stateDiffer = new StateDiffer(); + this.animationQueue = new AnimationQueue( + this.cardManager, + (playerId, position) => this.getSlotRect(playerId, position), + (location) => this.getLocationRectNew(location), + (type) => this.playSound(type) + ); + } + + // Get the bounding rect of a card slot + getSlotRect(playerId, position) { + // Try to find by data attribute first (new system) + const slotByData = document.querySelector(`.card-slot[data-player="${playerId}"][data-position="${position}"]`); + if (slotByData) { + const rect = slotByData.getBoundingClientRect(); + if (rect.width > 0) return rect; + } + + // Fallback: Check if it's the local player + if (playerId === this.playerId) { + const slots = this.playerCards.querySelectorAll('.card, .card-slot'); + if (slots[position]) { + return slots[position].getBoundingClientRect(); + } + } + return null; + } + + // Get rect for deck/discard/holding locations + getLocationRectNew(location) { + switch (location) { + case 'deck': + return this.deck.getBoundingClientRect(); + case 'discard': + return this.discard.getBoundingClientRect(); + case 'holding': { + const rect = this.discard.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height + }; + } + default: + return null; + } + } + + // Initialize persistent cards for a new game/round + initializePersistentCards() { + if (!this.cardManager || !this.gameState) return; + + this.cardManager.initializeCards( + this.gameState, + this.playerId, + (pid, pos) => this.getSlotRect(pid, pos), + () => this.deck.getBoundingClientRect(), + () => this.discard.getBoundingClientRect() + ); + + // Retry positioning a few times to handle layout delays + let retries = 0; + const tryPosition = () => { + const positioned = this.cardManager.updateAllPositions((pid, pos) => this.getSlotRect(pid, pos)); + retries++; + if (retries < 5) { + requestAnimationFrame(tryPosition); + } + }; + requestAnimationFrame(tryPosition); + } + + // Animate persistent cards based on detected movements + async animatePersistentCards(movements, newState) { + if (!this.cardManager) return; + + for (const movement of movements) { + switch (movement.type) { + case 'flip': + this.playSound('flip'); + await this.cardManager.flipCard( + movement.playerId, + movement.position, + movement.card + ); + break; + + case 'swap': + this.playSound('flip'); + await this.cardManager.animateSwap( + movement.playerId, + movement.position, + movement.oldCard, + movement.newCard, + (pid, pos) => this.getSlotRect(pid, pos), + () => this.discard.getBoundingClientRect() + ); + this.playSound('card'); + break; + + case 'draw-deck': + case 'draw-discard': + this.playSound('card'); + await this.delay(200); + break; + } + + // Small pause between animations + await this.delay(100); + } + } + + delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // Update persistent card positions and visual states + updatePersistentCards() { + if (!this.cardManager || !this.gameState) return; + + // If cards haven't been created yet, initialize them + if (this.cardManager.handCards.size === 0) { + // Need to wait for DOM to have slots - already handled in game_started + return; + } + + // Update positions (in case window resized) + this.cardManager.updateAllPositions((pid, pos) => this.getSlotRect(pid, pos)); + + // Update card visual states (clickable, selected) + const myData = this.getMyPlayerData(); + if (myData) { + for (let i = 0; i < 6; i++) { + const cardInfo = this.cardManager.getHandCard(this.playerId, i); + if (cardInfo) { + const card = myData.cards[i]; + const isClickable = ( + (this.gameState.waiting_for_initial_flip && !card.face_up) || + (this.drawnCard) || + (this.waitingForFlip && !card.face_up) + ); + const isSelected = this.selectedCards.includes(i); + + cardInfo.element.classList.toggle('clickable', isClickable); + cardInfo.element.classList.toggle('selected', isSelected); + + // Make card clickable + if (!cardInfo.element._hasClickHandler) { + const pos = i; + cardInfo.element.addEventListener('click', () => this.handleCardClick(pos)); + cardInfo.element._hasClickHandler = true; + } + } + } + } + } + initElements() { // Screens this.lobbyScreen = document.getElementById('lobby-screen'); @@ -142,19 +335,18 @@ class GolfGame { // Game elements this.currentRoundSpan = document.getElementById('current-round'); this.totalRoundsSpan = document.getElementById('total-rounds'); - this.turnInfo = document.getElementById('turn-info'); + this.statusMessage = document.getElementById('status-message'); + this.playerHeader = document.getElementById('player-header'); this.yourScore = document.getElementById('your-score'); this.muteBtn = document.getElementById('mute-btn'); this.opponentsRow = document.getElementById('opponents-row'); this.deck = document.getElementById('deck'); this.discard = document.getElementById('discard'); this.discardContent = document.getElementById('discard-content'); - this.drawnCardArea = document.getElementById('drawn-card-area'); - this.drawnCardEl = document.getElementById('drawn-card'); this.discardBtn = document.getElementById('discard-btn'); this.playerCards = document.getElementById('player-cards'); - this.flipPrompt = document.getElementById('flip-prompt'); - this.toast = document.getElementById('toast'); + this.swapAnimation = document.getElementById('swap-animation'); + this.swapCardFromHand = document.getElementById('swap-card-from-hand'); this.scoreboard = document.getElementById('scoreboard'); this.scoreTable = document.getElementById('score-table').querySelector('tbody'); this.standingsList = document.getElementById('standings-list'); @@ -288,14 +480,85 @@ class GolfGame { case 'game_started': case 'round_started': this.gameState = data.game_state; + // Deep copy for previousState to avoid reference issues + this.previousState = JSON.parse(JSON.stringify(data.game_state)); + // Reset tracking for new round + this.locallyFlippedCards = new Set(); + this.animatingPositions = new Set(); this.playSound('shuffle'); this.showGameScreen(); + if (USE_NEW_CARD_SYSTEM && this.cardManager) { + this.cardManager.clear(); // Clear any leftover cards + } this.renderGame(); + // Initialize persistent cards after DOM is ready + if (USE_NEW_CARD_SYSTEM && this.cardManager) { + setTimeout(() => this.initializePersistentCards(), 50); + } break; case 'game_state': - this.gameState = data.game_state; - this.renderGame(); + if (USE_NEW_CARD_SYSTEM) { + // New card system: animate persistent cards directly + if (this.isAnimating) { + this.pendingGameState = data.game_state; + break; + } + + const movements = this.stateDiffer.diff(this.previousState, data.game_state); + + if (movements.length > 0) { + this.isAnimating = true; + this.animatePersistentCards(movements, data.game_state).then(() => { + this.isAnimating = false; + this.gameState = data.game_state; + this.previousState = JSON.parse(JSON.stringify(data.game_state)); + this.renderGame(); + + if (this.pendingGameState) { + const pending = this.pendingGameState; + this.pendingGameState = null; + this.handleMessage({ type: 'game_state', game_state: pending }); + } + }); + } else { + this.gameState = data.game_state; + this.previousState = JSON.parse(JSON.stringify(data.game_state)); + this.renderGame(); + } + } else { + // Legacy animation system - simplified + // Principle: State updates are instant, animations are fire-and-forget + // Exception: Local player's swap animation defers state until complete + + // If local swap animation is running, defer this state update + if (this.swapAnimationInProgress) { + this.updateSwapAnimation(data.game_state.discard_top); + this.pendingGameState = data.game_state; + break; + } + + const oldState = this.gameState; + const newState = data.game_state; + + // Update state FIRST (always) + this.gameState = newState; + + // Clear local flip tracking if server confirmed our flips + if (!newState.waiting_for_initial_flip && oldState?.waiting_for_initial_flip) { + this.locallyFlippedCards = new Set(); + } + + // Detect and fire animations (non-blocking, errors shouldn't break game) + try { + this.triggerAnimationsForStateChange(oldState, newState); + } catch (e) { + console.error('Animation error:', e); + } + + // Render immediately with new state + this.renderGame(); + } break; case 'your_turn': @@ -557,11 +820,477 @@ class GolfGame { this.hideDrawnCard(); } + // New card system swap animation + animateSwapNew(position) { + if (!this.drawnCard) return; + + // Send swap immediately - animation happens via state diff + this.send({ type: 'swap', position }); + this.drawnCard = null; + this.hideDrawnCard(); + } + + animateSwap(position) { + const cardElements = this.playerCards.querySelectorAll('.card'); + const handCardEl = cardElements[position]; + if (!handCardEl) { + this.swapCard(position); + return; + } + + // Check if card is already face-up (no flip needed) + const myData = this.getMyPlayerData(); + const card = myData?.cards[position]; + const isAlreadyFaceUp = card?.face_up; + + // Get positions + const handRect = handCardEl.getBoundingClientRect(); + const discardRect = this.discard.getBoundingClientRect(); + + // Set up the animated card at hand position + const swapCard = this.swapCardFromHand; + const swapCardFront = swapCard.querySelector('.swap-card-front'); + + // Position at the hand card location + swapCard.style.left = handRect.left + 'px'; + swapCard.style.top = handRect.top + 'px'; + swapCard.style.width = handRect.width + 'px'; + swapCard.style.height = handRect.height + 'px'; + + // Reset state + swapCard.classList.remove('flipping', 'moving'); + swapCardFront.innerHTML = ''; + swapCardFront.className = 'swap-card-front'; + + // If already face-up, show the card content immediately + if (isAlreadyFaceUp && card) { + if (card.rank === '★') { + swapCardFront.classList.add('joker'); + const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹'; + swapCardFront.innerHTML = `${jokerIcon}Joker`; + } else { + swapCardFront.classList.add(card.suit === 'hearts' || card.suit === 'diamonds' ? 'red' : 'black'); + const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[card.suit]; + swapCardFront.innerHTML = `${card.rank}
${suitSymbol}`; + } + swapCard.classList.add('flipping'); // Start showing front immediately + } + + // Hide the actual hand card + handCardEl.classList.add('swap-out'); + + // Hide the discard (drawn card) + this.discard.classList.add('swap-to-hand'); + + // Show animation overlay + this.swapAnimation.classList.remove('hidden'); + + // Mark that we're animating - defer game state renders + this.swapAnimationInProgress = true; + this.swapAnimationCardEl = handCardEl; + this.swapAnimationFront = swapCardFront; + this.swapAnimationContentSet = isAlreadyFaceUp; // Skip updateSwapAnimation if we already set content + + // Send swap immediately so server can respond + this.send({ type: 'swap', position }); + this.drawnCard = null; + + // Timing depends on whether we need to flip first + const flipDelay = isAlreadyFaceUp ? 0 : 450; + + // Step 1: Flip the card over (only if face-down) + if (!isAlreadyFaceUp) { + setTimeout(() => { + swapCard.classList.add('flipping'); + }, 50); + } + + // Step 2: Move to discard position + setTimeout(() => { + swapCard.classList.add('moving'); + swapCard.style.left = discardRect.left + 'px'; + swapCard.style.top = discardRect.top + 'px'; + }, flipDelay + 50); + + // Step 3: Card has landed - pause to show the card + setTimeout(() => { + swapCard.classList.remove('moving'); + }, flipDelay + 400); + + // Step 4: Complete animation and render final state + setTimeout(() => { + // Hide animation overlay + this.swapAnimation.classList.add('hidden'); + swapCard.classList.remove('flipping', 'moving'); + + // Reset card states + handCardEl.classList.remove('swap-out'); + this.discard.classList.remove('swap-to-hand'); + + // Now allow renders and show the final state + this.swapAnimationInProgress = false; + this.swapAnimationContentSet = false; + this.hideDrawnCard(); + + // Render the pending game state if we have one + if (this.pendingGameState) { + this.gameState = this.pendingGameState; + this.pendingGameState = null; + this.renderGame(); + } + }, flipDelay + 900); + } + + // Update the animated card with actual card content when server responds + updateSwapAnimation(card) { + if (!this.swapAnimationFront || !card) return; + + // Skip if we already set the content (face-up card swap) + if (this.swapAnimationContentSet) return; + + // Set card color class + this.swapAnimationFront.className = 'swap-card-front'; + if (card.rank === '★') { + this.swapAnimationFront.classList.add('joker'); + const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹'; + this.swapAnimationFront.innerHTML = `${jokerIcon}Joker`; + } else { + if (card.suit === 'hearts' || card.suit === 'diamonds') { + this.swapAnimationFront.classList.add('red'); + } else { + this.swapAnimationFront.classList.add('black'); + } + this.swapAnimationFront.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`; + } + } + flipCard(position) { this.send({ type: 'flip_card', position }); this.waitingForFlip = false; } + // Fire-and-forget animation triggers based on state changes + triggerAnimationsForStateChange(oldState, newState) { + if (!oldState) return; + + // Check for discard pile changes + const newDiscard = newState.discard_top; + const oldDiscard = oldState.discard_top; + const discardChanged = newDiscard && (!oldDiscard || + newDiscard.rank !== oldDiscard.rank || + newDiscard.suit !== oldDiscard.suit); + + const previousPlayerId = oldState.current_player_id; + const wasOtherPlayer = previousPlayerId && previousPlayerId !== this.playerId; + + if (discardChanged && wasOtherPlayer) { + // Check if the previous player actually SWAPPED (has a new face-up card) + // vs just discarding the drawn card (no hand change) + const oldPlayer = oldState.players.find(p => p.id === previousPlayerId); + const newPlayer = newState.players.find(p => p.id === previousPlayerId); + + if (oldPlayer && newPlayer) { + // Find the position that changed + // Could be: face-down -> face-up (new reveal) + // Or: different card at same position (replaced visible card) + let swappedPosition = -1; + for (let i = 0; i < 6; i++) { + const oldCard = oldPlayer.cards[i]; + const newCard = newPlayer.cards[i]; + const wasUp = oldCard?.face_up; + const isUp = newCard?.face_up; + + // Case 1: face-down became face-up + if (!wasUp && isUp) { + swappedPosition = i; + break; + } + // Case 2: both face-up but different card (rank or suit changed) + if (wasUp && isUp && oldCard.rank && newCard.rank) { + if (oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit) { + swappedPosition = i; + break; + } + } + } + + if (swappedPosition >= 0) { + // Player swapped - animate from the actual position that changed + this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition); + } else { + // Player drew and discarded without swapping + // Animate card going from deck area to discard + this.fireDiscardAnimation(newDiscard); + } + } + } + + // Note: We don't separately animate card flips for swaps anymore + // The swap animation handles showing the card at the correct position + } + + // Fire animation for discard without swap (card goes deck -> discard) + fireDiscardAnimation(discardCard) { + const deckRect = this.deck.getBoundingClientRect(); + const discardRect = this.discard.getBoundingClientRect(); + const swapCard = this.swapCardFromHand; + const swapCardFront = swapCard.querySelector('.swap-card-front'); + + // Start at deck position + swapCard.style.left = deckRect.left + 'px'; + swapCard.style.top = deckRect.top + 'px'; + swapCard.style.width = deckRect.width + 'px'; + swapCard.style.height = deckRect.height + 'px'; + swapCard.classList.remove('flipping', 'moving'); + + // Set card content + swapCardFront.className = 'swap-card-front'; + if (discardCard.rank === '★') { + swapCardFront.classList.add('joker'); + const jokerIcon = discardCard.suit === 'hearts' ? '🐉' : '👹'; + swapCardFront.innerHTML = `${jokerIcon}Joker`; + } else { + swapCardFront.classList.add(discardCard.suit === 'hearts' || discardCard.suit === 'diamonds' ? 'red' : 'black'); + swapCardFront.innerHTML = `${discardCard.rank}
${this.getSuitSymbol(discardCard.suit)}`; + } + + this.swapAnimation.classList.remove('hidden'); + + // Flip to reveal card + setTimeout(() => { + swapCard.classList.add('flipping'); + this.playSound('flip'); + }, 50); + + // Move to discard + setTimeout(() => { + swapCard.classList.add('moving'); + swapCard.style.left = discardRect.left + 'px'; + swapCard.style.top = discardRect.top + 'px'; + }, 400); + + // Complete + setTimeout(() => { + this.swapAnimation.classList.add('hidden'); + swapCard.classList.remove('flipping', 'moving'); + }, 800); + } + + // Get rotation angle from an element's computed transform + getElementRotation(element) { + if (!element) return 0; + const style = window.getComputedStyle(element); + const transform = style.transform; + if (!transform || transform === 'none') return 0; + + // Parse rotation from transform matrix + const values = transform.split('(')[1]?.split(')')[0]?.split(','); + if (values && values.length >= 2) { + const a = parseFloat(values[0]); + const b = parseFloat(values[1]); + return Math.round(Math.atan2(b, a) * (180 / Math.PI)); + } + return 0; + } + + // Fire a swap animation (non-blocking) + fireSwapAnimation(playerId, discardCard, position) { + + // Find source position - the actual card that was swapped + const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area'); + let sourceRect = null; + let sourceCardEl = null; + let sourceRotation = 0; + + for (const area of opponentAreas) { + const nameEl = area.querySelector('h4'); + const player = this.gameState?.players.find(p => p.id === playerId); + if (nameEl && player && nameEl.textContent.includes(player.name)) { + const cards = area.querySelectorAll('.card'); + if (cards.length > position && position >= 0) { + sourceCardEl = cards[position]; + sourceRect = sourceCardEl.getBoundingClientRect(); + // Get rotation from the opponent area (parent has the arch rotation) + sourceRotation = this.getElementRotation(area); + } + break; + } + } + + if (!sourceRect) { + const discardRect = this.discard.getBoundingClientRect(); + sourceRect = { left: discardRect.left, top: discardRect.top - 100, width: discardRect.width, height: discardRect.height }; + } + + const discardRect = this.discard.getBoundingClientRect(); + const swapCard = this.swapCardFromHand; + const swapCardFront = swapCard.querySelector('.swap-card-front'); + const swapCardInner = swapCard.querySelector('.swap-card-inner'); + + swapCard.style.left = sourceRect.left + 'px'; + swapCard.style.top = sourceRect.top + 'px'; + swapCard.style.width = sourceRect.width + 'px'; + swapCard.style.height = sourceRect.height + 'px'; + swapCard.classList.remove('flipping', 'moving'); + + // Apply source rotation to match the arch layout + swapCard.style.transform = `rotate(${sourceRotation}deg)`; + + // Set card content + swapCardFront.className = 'swap-card-front'; + if (discardCard.rank === '★') { + swapCardFront.classList.add('joker'); + const jokerIcon = discardCard.suit === 'hearts' ? '🐉' : '👹'; + swapCardFront.innerHTML = `${jokerIcon}Joker`; + } else { + swapCardFront.classList.add(discardCard.suit === 'hearts' || discardCard.suit === 'diamonds' ? 'red' : 'black'); + swapCardFront.innerHTML = `${discardCard.rank}
${this.getSuitSymbol(discardCard.suit)}`; + } + + if (sourceCardEl) sourceCardEl.classList.add('swap-out'); + this.swapAnimation.classList.remove('hidden'); + + // Timing: flip takes ~400ms, then move takes ~400ms + setTimeout(() => { + swapCard.classList.add('flipping'); + this.playSound('flip'); + }, 50); + setTimeout(() => { + // Start move AFTER flip completes - also animate rotation back to 0 + swapCard.classList.add('moving'); + swapCard.style.left = discardRect.left + 'px'; + swapCard.style.top = discardRect.top + 'px'; + swapCard.style.transform = 'rotate(0deg)'; + }, 500); + setTimeout(() => { + this.swapAnimation.classList.add('hidden'); + swapCard.classList.remove('flipping', 'moving'); + swapCard.style.transform = ''; + if (sourceCardEl) sourceCardEl.classList.remove('swap-out'); + }, 1000); + } + + // Fire a flip animation for local player's card (non-blocking) + fireLocalFlipAnimation(position, cardData) { + const key = `local-${position}`; + if (this.animatingPositions.has(key)) return; + this.animatingPositions.add(key); + + const cardElements = this.playerCards.querySelectorAll('.card'); + const cardEl = cardElements[position]; + if (!cardEl) { + this.animatingPositions.delete(key); + return; + } + + const cardRect = cardEl.getBoundingClientRect(); + const swapCard = this.swapCardFromHand; + const swapCardFront = swapCard.querySelector('.swap-card-front'); + + swapCard.style.left = cardRect.left + 'px'; + swapCard.style.top = cardRect.top + 'px'; + swapCard.style.width = cardRect.width + 'px'; + swapCard.style.height = cardRect.height + 'px'; + swapCard.classList.remove('flipping', 'moving'); + + // Set card content + swapCardFront.className = 'swap-card-front'; + if (cardData.rank === '★') { + swapCardFront.classList.add('joker'); + const jokerIcon = cardData.suit === 'hearts' ? '🐉' : '👹'; + swapCardFront.innerHTML = `${jokerIcon}Joker`; + } else { + swapCardFront.classList.add(cardData.suit === 'hearts' || cardData.suit === 'diamonds' ? 'red' : 'black'); + const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[cardData.suit]; + swapCardFront.innerHTML = `${cardData.rank}
${suitSymbol}`; + } + + cardEl.classList.add('swap-out'); + this.swapAnimation.classList.remove('hidden'); + + setTimeout(() => { + swapCard.classList.add('flipping'); + this.playSound('flip'); + }, 50); + + setTimeout(() => { + this.swapAnimation.classList.add('hidden'); + swapCard.classList.remove('flipping'); + cardEl.classList.remove('swap-out'); + this.animatingPositions.delete(key); + }, 450); + } + + // Fire a flip animation for opponent card (non-blocking) + fireFlipAnimation(playerId, position, cardData) { + // Skip if already animating this position + const key = `${playerId}-${position}`; + if (this.animatingPositions.has(key)) return; + this.animatingPositions.add(key); + + // Find the card element and parent area (for rotation) + const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area'); + let cardEl = null; + let sourceRotation = 0; + + for (const area of opponentAreas) { + const nameEl = area.querySelector('h4'); + const player = this.gameState?.players.find(p => p.id === playerId); + if (nameEl && player && nameEl.textContent.includes(player.name)) { + const cards = area.querySelectorAll('.card'); + cardEl = cards[position]; + sourceRotation = this.getElementRotation(area); + break; + } + } + + if (!cardEl) { + this.animatingPositions.delete(key); + return; + } + + const cardRect = cardEl.getBoundingClientRect(); + const swapCard = this.swapCardFromHand; + const swapCardFront = swapCard.querySelector('.swap-card-front'); + + swapCard.style.left = cardRect.left + 'px'; + swapCard.style.top = cardRect.top + 'px'; + swapCard.style.width = cardRect.width + 'px'; + swapCard.style.height = cardRect.height + 'px'; + swapCard.classList.remove('flipping', 'moving'); + + // Apply rotation to match the arch layout + swapCard.style.transform = `rotate(${sourceRotation}deg)`; + + // Set card content + swapCardFront.className = 'swap-card-front'; + if (cardData.rank === '★') { + swapCardFront.classList.add('joker'); + const jokerIcon = cardData.suit === 'hearts' ? '🐉' : '👹'; + swapCardFront.innerHTML = `${jokerIcon}Joker`; + } else { + swapCardFront.classList.add(cardData.suit === 'hearts' || cardData.suit === 'diamonds' ? 'red' : 'black'); + const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[cardData.suit]; + swapCardFront.innerHTML = `${cardData.rank}
${suitSymbol}`; + } + + cardEl.classList.add('swap-out'); + this.swapAnimation.classList.remove('hidden'); + + setTimeout(() => { + swapCard.classList.add('flipping'); + this.playSound('flip'); + }, 50); + + setTimeout(() => { + this.swapAnimation.classList.add('hidden'); + swapCard.classList.remove('flipping'); + swapCard.style.transform = ''; + cardEl.classList.remove('swap-out'); + this.animatingPositions.delete(key); + }, 450); + } + handleCardClick(position) { const myData = this.getMyPlayerData(); if (!myData) return; @@ -571,15 +1300,19 @@ class GolfGame { // Initial flip phase if (this.gameState.waiting_for_initial_flip) { if (card.face_up) return; + if (this.locallyFlippedCards.has(position)) return; - this.playSound('flip'); const requiredFlips = this.gameState.initial_flips || 2; - if (this.selectedCards.includes(position)) { - this.selectedCards = this.selectedCards.filter(p => p !== position); - } else { - this.selectedCards.push(position); - } + // Track locally and animate immediately + this.locallyFlippedCards.add(position); + this.selectedCards.push(position); + + // Fire flip animation (non-blocking) + this.fireLocalFlipAnimation(position, card); + + // Re-render to show flipped state + this.renderGame(); if (this.selectedCards.length === requiredFlips) { this.send({ type: 'flip_initial', positions: this.selectedCards }); @@ -589,19 +1322,24 @@ class GolfGame { const remaining = requiredFlips - this.selectedCards.length; this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000); } - this.renderGame(); return; } // Swap with drawn card if (this.drawnCard) { - this.swapCard(position); + if (USE_NEW_CARD_SYSTEM) { + this.animateSwapNew(position); + } else { + this.animateSwap(position); + } this.hideToast(); return; } - // Flip after discarding from deck + // Flip after discarding from deck (flip_on_discard variant) if (this.waitingForFlip && !card.face_up) { + // Animate immediately, then send to server + this.fireLocalFlipAnimation(position, card); this.flipCard(position); this.hideToast(); return; @@ -648,6 +1386,11 @@ class GolfGame { this.playerId = null; this.isHost = false; this.gameState = null; + this.previousState = null; + // Clear card layer + if (USE_NEW_CARD_SYSTEM && this.cardManager) { + this.cardManager.clear(); + } } showWaitingRoom() { @@ -669,10 +1412,15 @@ class GolfGame { this.drawnCard = null; this.selectedCards = []; this.waitingForFlip = false; + this.previousState = null; // Update leave button text based on role this.leaveGameBtn.textContent = this.isHost ? 'End Game' : 'Leave'; // Update active rules bar this.updateActiveRulesBar(); + // Clear card layer for new card system + if (USE_NEW_CARD_SYSTEM && this.cardManager) { + this.cardManager.clear(); + } } updateActiveRulesBar() { @@ -757,43 +1505,64 @@ class GolfGame { return this.gameState.players.find(p => p.id === this.playerId); } - showToast(message, type = '', duration = 2500) { - this.toast.textContent = message; - this.toast.className = 'toast' + (type ? ' ' + type : ''); + setStatus(message, type = '') { + this.statusMessage.textContent = message; + this.statusMessage.className = 'status-message' + (type ? ' ' + type : ''); + } - clearTimeout(this.toastTimeout); - this.toastTimeout = setTimeout(() => { - this.toast.classList.add('hidden'); - }, duration); + showToast(message, type = '', duration = 2500) { + // For compatibility - just set the status message + this.setStatus(message, type); } hideToast() { - this.toast.classList.add('hidden'); - clearTimeout(this.toastTimeout); + // Restore default status based on game state + this.updateStatusFromGameState(); } - showDrawnCard() { - this.drawnCardArea.classList.remove('hidden'); - // Drawn card is always revealed to the player, so render directly - const card = this.drawnCard; - this.drawnCardEl.className = 'card card-front'; + updateStatusFromGameState() { + if (!this.gameState) { + this.setStatus(''); + return; + } - // Handle jokers specially - if (card.rank === '★') { - this.drawnCardEl.innerHTML = '★
JOKER'; - this.drawnCardEl.classList.add('joker'); + const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id); + if (currentPlayer && currentPlayer.id !== this.playerId) { + this.setStatus(`${currentPlayer.name}'s turn`); + } else if (this.isMyTurn()) { + this.setStatus('Your turn - draw a card', 'your-turn'); } else { - this.drawnCardEl.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`; - if (this.isRedSuit(card.suit)) { - this.drawnCardEl.classList.add('red'); - } else { - this.drawnCardEl.classList.add('black'); - } + this.setStatus(''); } } + showDrawnCard() { + // Show drawn card in the discard pile position, highlighted + const card = this.drawnCard; + + this.discard.className = 'card card-front holding'; + if (card.rank === '★') { + this.discard.classList.add('joker'); + } else if (this.isRedSuit(card.suit)) { + this.discard.classList.add('red'); + } else { + this.discard.classList.add('black'); + } + + // Render card directly without checking face_up (drawn card is always visible to drawer) + if (card.rank === '★') { + const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹'; + this.discardContent.innerHTML = `${jokerIcon}Joker`; + } else { + this.discardContent.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`; + } + this.discardBtn.classList.remove('hidden'); + } + hideDrawnCard() { - this.drawnCardArea.classList.add('hidden'); + // Restore discard pile to show actual top card (handled by renderGame) + this.discard.classList.remove('holding'); + this.discardBtn.classList.add('hidden'); } isRedSuit(suit) { @@ -849,9 +1618,10 @@ class GolfGame { renderCardContent(card) { if (!card || !card.face_up) return ''; - // Jokers show star symbol without suit + // Jokers - use suit to determine icon (hearts = dragon, spades = oni) if (card.rank === '★') { - return '★
JOKER'; + const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹'; + return `${jokerIcon}Joker`; } return `${card.rank}
${this.getSuitSymbol(card.suit)}`; } @@ -863,53 +1633,56 @@ class GolfGame { this.currentRoundSpan.textContent = this.gameState.current_round; this.totalRoundsSpan.textContent = this.gameState.total_rounds; - // Update turn info + // Update status message (handled by specific actions, but set default here) const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id); - if (currentPlayer) { - if (currentPlayer.id === this.playerId) { - this.turnInfo.textContent = "Your turn!"; - this.turnInfo.style.color = "#f4a460"; - } else { - this.turnInfo.textContent = `${currentPlayer.name}'s turn`; - this.turnInfo.style.color = "#fff"; - } + if (currentPlayer && currentPlayer.id !== this.playerId) { + this.setStatus(`${currentPlayer.name}'s turn`); } - // Update your score (points currently showing on your cards) + // Update player header (name + score like opponents) const me = this.gameState.players.find(p => p.id === this.playerId); if (me) { // Calculate visible score from face-up cards const showingScore = this.calculateShowingScore(me.cards); this.yourScore.textContent = showingScore; + + // Update player name in header (truncate if needed) + const displayName = me.name.length > 12 ? me.name.substring(0, 11) + '…' : me.name; + const checkmark = me.all_face_up ? ' ✓' : ''; + // Set text content before the score span + this.playerHeader.childNodes[0].textContent = displayName + checkmark; } - // Update discard pile - if (this.gameState.discard_top) { - const discardCard = this.gameState.discard_top; - const cardKey = `${discardCard.rank}-${discardCard.suit}`; + // Update discard pile (skip if holding a drawn card) + if (!this.drawnCard) { + if (this.gameState.discard_top) { + const discardCard = this.gameState.discard_top; + const cardKey = `${discardCard.rank}-${discardCard.suit}`; - // Animate if discard changed - if (this.lastDiscardKey && this.lastDiscardKey !== cardKey) { - this.discard.classList.add('card-flip-in'); - setTimeout(() => this.discard.classList.remove('card-flip-in'), 400); - } - this.lastDiscardKey = cardKey; + // Animate if discard changed + if (this.lastDiscardKey && this.lastDiscardKey !== cardKey) { + this.discard.classList.add('card-flip-in'); + setTimeout(() => this.discard.classList.remove('card-flip-in'), 400); + } + this.lastDiscardKey = cardKey; - this.discard.classList.add('has-card', 'card-front'); - this.discard.classList.remove('card-back', 'red', 'black', 'joker'); + this.discard.classList.add('has-card', 'card-front'); + this.discard.classList.remove('card-back', 'red', 'black', 'joker', 'holding'); - if (discardCard.rank === '★') { - this.discard.classList.add('joker'); - } else if (this.isRedSuit(discardCard.suit)) { - this.discard.classList.add('red'); + if (discardCard.rank === '★') { + this.discard.classList.add('joker'); + } else if (this.isRedSuit(discardCard.suit)) { + this.discard.classList.add('red'); + } else { + this.discard.classList.add('black'); + } + this.discardContent.innerHTML = this.renderCardContent(discardCard); } else { - this.discard.classList.add('black'); + this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding'); + this.discardContent.innerHTML = ''; + this.lastDiscardKey = null; } - this.discardContent.innerHTML = this.renderCardContent(discardCard); - } else { - this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker'); - this.discardContent.innerHTML = ''; - this.lastDiscardKey = null; + this.discardBtn.classList.add('hidden'); } // Update deck/discard clickability and visual state @@ -920,7 +1693,8 @@ class GolfGame { this.deck.classList.toggle('disabled', hasDrawn); this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top); - this.discard.classList.toggle('disabled', hasDrawn); + // Don't show disabled state when we're holding a drawn card (it's displayed in discard position) + this.discard.classList.toggle('disabled', hasDrawn && !this.drawnCard); // Render opponents in a single row const opponents = this.gameState.players.filter(p => p.id !== this.playerId); @@ -937,12 +1711,22 @@ class GolfGame { const displayName = player.name.length > 12 ? player.name.substring(0, 11) + '…' : player.name; const showingScore = this.calculateShowingScore(player.cards); - div.innerHTML = ` -

${displayName}${player.all_face_up ? ' ✓' : ''}${showingScore}

-
- ${player.cards.map(card => this.renderCard(card, false, false)).join('')} -
- `; + if (USE_NEW_CARD_SYSTEM) { + // Render empty slots - cards are in card-layer + div.innerHTML = ` +

${displayName}${player.all_face_up ? ' ✓' : ''}${showingScore}

+
+ ${player.cards.map((_, i) => `
`).join('')} +
+ `; + } else { + div.innerHTML = ` +

${displayName}${player.all_face_up ? ' ✓' : ''}${showingScore}

+
+ ${player.cards.map(card => this.renderCard(card, false, false)).join('')} +
+ `; + } this.opponentsRow.appendChild(div); }); @@ -952,33 +1736,62 @@ class GolfGame { if (myData) { this.playerCards.innerHTML = ''; - myData.cards.forEach((card, index) => { - const isClickable = ( - (this.gameState.waiting_for_initial_flip && !card.face_up) || - (this.drawnCard) || - (this.waitingForFlip && !card.face_up) - ); - const isSelected = this.selectedCards.includes(index); + if (USE_NEW_CARD_SYSTEM) { + // Render empty slots - cards are in card-layer + myData.cards.forEach((card, index) => { + const isClickable = ( + (this.gameState.waiting_for_initial_flip && !card.face_up) || + (this.drawnCard) || + (this.waitingForFlip && !card.face_up) + ); + const isSelected = this.selectedCards.includes(index); - const cardEl = document.createElement('div'); - cardEl.innerHTML = this.renderCard(card, isClickable, isSelected); - cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index)); - this.playerCards.appendChild(cardEl.firstChild); - }); + const slotEl = document.createElement('div'); + slotEl.className = 'card-slot'; + slotEl.dataset.player = this.playerId; + slotEl.dataset.position = index; + if (isClickable) slotEl.classList.add('clickable'); + if (isSelected) slotEl.classList.add('selected'); + slotEl.addEventListener('click', () => this.handleCardClick(index)); + this.playerCards.appendChild(slotEl); + }); + + // Update persistent card positions and states + this.updatePersistentCards(); + } else { + myData.cards.forEach((card, index) => { + // Check if this card was locally flipped (immediate feedback) + const isLocallyFlipped = this.locallyFlippedCards.has(index); + + // Create a display card that shows face-up if locally flipped + const displayCard = isLocallyFlipped + ? { ...card, face_up: true } + : card; + + const isClickable = ( + (this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped) || + (this.drawnCard) || + (this.waitingForFlip && !card.face_up) + ); + const isSelected = this.selectedCards.includes(index); + + const cardEl = document.createElement('div'); + cardEl.innerHTML = this.renderCard(displayCard, isClickable, isSelected); + cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index)); + this.playerCards.appendChild(cardEl.firstChild); + }); + } } // Show flip prompt for initial flip + // Show flip prompt during initial flip phase if (this.gameState.waiting_for_initial_flip) { const requiredFlips = this.gameState.initial_flips || 2; - const remaining = requiredFlips - this.selectedCards.length; + const flippedCount = this.locallyFlippedCards.size; + const remaining = requiredFlips - flippedCount; if (remaining > 0) { - this.flipPrompt.textContent = `Select ${remaining} card${remaining > 1 ? 's' : ''} to flip`; - this.flipPrompt.classList.remove('hidden'); - } else { - this.flipPrompt.classList.add('hidden'); + this.setStatus(`Select ${remaining} card${remaining > 1 ? 's' : ''} to flip`, 'your-turn'); } - } else { - this.flipPrompt.classList.add('hidden'); } // Disable discard button if can't discard (must_swap_discard rule) @@ -1047,7 +1860,7 @@ class GolfGame { } const medal = pointsRank === 0 ? '🥇' : pointsRank === 1 ? '🥈' : pointsRank === 2 ? '🥉' : '4.'; const name = p.name.length > 8 ? p.name.substring(0, 7) + '…' : p.name; - return `
${medal}${name}${p.total_score}pt
`; + return `
${medal}${name}${p.total_score} pts
`; }).join(''); // Build holes won ranking @@ -1061,7 +1874,7 @@ class GolfGame { const medal = p.rounds_won === 0 ? '-' : holesRank === 0 ? '🥇' : holesRank === 1 ? '🥈' : holesRank === 2 ? '🥉' : '4.'; const name = p.name.length > 8 ? p.name.substring(0, 7) + '…' : p.name; - return `
${medal}${name}${p.rounds_won}W
`; + return `
${medal}${name}${p.rounds_won} wins
`; }).join(''); this.standingsList.innerHTML = ` @@ -1183,7 +1996,7 @@ class GolfGame { } const medal = pointsRank === 0 ? '🥇' : pointsRank === 1 ? '🥈' : pointsRank === 2 ? '🥉' : `${pointsRank + 1}.`; const name = p.name.length > 12 ? p.name.substring(0, 11) + '…' : p.name; - return `
${medal}${name}${p.total}pt
`; + return `
${medal}${name}${p.total} pts
`; }).join(''); // Build holes won ranking (most wins) with tie handling @@ -1198,7 +2011,7 @@ class GolfGame { const medal = p.rounds_won === 0 ? '-' : holesRank === 0 ? '🥇' : holesRank === 1 ? '🥈' : holesRank === 2 ? '🥉' : `${holesRank + 1}.`; const name = p.name.length > 12 ? p.name.substring(0, 11) + '…' : p.name; - return `
${medal}${name}${p.rounds_won}W
`; + return `
${medal}${name}${p.rounds_won} wins
`; }).join(''); // If double victory, show banner above the left panel (standings) diff --git a/client/card-manager.js b/client/card-manager.js new file mode 100644 index 0000000..391f7f5 --- /dev/null +++ b/client/card-manager.js @@ -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 = ` +
+
+
?
+
+ `; + + 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 = `${icon}Joker`; + } else { + const isRed = cardData.suit === 'hearts' || cardData.suit === 'diamonds'; + front.classList.add(isRed ? 'red' : 'black'); + front.innerHTML = `${cardData.rank}
${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 = `${icon}Joker`; + } else { + const isRed = newCardData.suit === 'hearts' || newCardData.suit === 'diamonds'; + front.classList.add(isRed ? 'red' : 'black'); + front.innerHTML = `${newCardData.rank}
${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 = `${icon}Joker`; + } else { + const isRed = oldCardData.suit === 'hearts' || oldCardData.suit === 'diamonds'; + front.classList.add(isRed ? 'red' : 'black'); + front.innerHTML = `${oldCardData.rank}
${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 = `${icon}Joker`; + } else { + const isRed = newCardData.suit === 'hearts' || newCardData.suit === 'diamonds'; + front.classList.add(isRed ? 'red' : 'black'); + front.innerHTML = `${newCardData.rank}
${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; +} diff --git a/client/index.html b/client/index.html index b54b7fd..9309d7d 100644 --- a/client/index.html +++ b/client/index.html @@ -200,19 +200,24 @@
+ +
Hole 1/9
- @@ -225,22 +230,30 @@
?
-
- +
+
+ +
+
-
-
+

You0

- +
+ + +
@@ -287,6 +300,9 @@
+ + + diff --git a/client/state-differ.js b/client/state-differ.js new file mode 100644 index 0000000..c429e92 --- /dev/null +++ b/client/state-differ.js @@ -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; +} diff --git a/client/style.css b/client/style.css index c0bbb84..4be6d01 100644 --- a/client/style.css +++ b/client/style.css @@ -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; diff --git a/lib64 b/lib64 new file mode 120000 index 0000000..7951405 --- /dev/null +++ b/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5e279de --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/pyvenv.cfg b/pyvenv.cfg new file mode 100644 index 0000000..a652a18 --- /dev/null +++ b/pyvenv.cfg @@ -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 diff --git a/server/RULES.md b/server/RULES.md index cd1d612..37620b1 100644 --- a/server/RULES.md +++ b/server/RULES.md @@ -1,6 +1,19 @@ # 6-Card Golf Rules -This document defines the canonical rules implemented in this game engine, based on standard 6-Card Golf rules from [Pagat.com](https://www.pagat.com/draw/golf.html) and [Bicycle Cards](https://bicyclecards.com/how-to-play/six-card-golf). +> **Single Source of Truth** for all game rules, variants, and house rules. +> This document is the canonical reference - all implementations must match these specifications. + +## Document Structure + +This document follows a **vertical documentation structure**: +1. **Rules** - Human-readable game rules +2. **Implementation** - Code references (file:line) +3. **Tests** - Verification test references +4. **Edge Cases** - Documented edge case behaviors + +--- + +# Part 1: Core Game Rules ## Overview @@ -12,48 +25,141 @@ Golf is a card game where players try to achieve the **lowest score** over multi - **Deck:** Standard 52-card deck (optionally with 2 Jokers) - **Multiple decks:** For 5+ players, use 2 decks +| Implementation | File | +|----------------|------| +| Deck creation | `game.py:89-104` | +| Multi-deck support | `game.py:92-103` | + +| Tests | File | +|-------|------| +| Standard 52 cards | `test_game.py:501-504` | +| Joker deck 54 cards | `test_game.py:506-509` | +| Multi-deck | `test_game.py:516-519` | + ## Setup 1. Dealer shuffles and deals **6 cards face-down** to each player -2. Players arrange cards in a **2 row × 3 column grid**: +2. Players arrange cards in a **2 row x 3 column grid**: ``` - [0] [1] [2] ← Top row - [3] [4] [5] ← Bottom row + [0] [1] [2] <- Top row + [3] [4] [5] <- Bottom row ``` 3. Remaining cards form the **draw pile** (face-down) 4. Top card of draw pile is flipped to start the **discard pile** -5. Each player flips **2 of their cards** face-up (standard rules) +5. Each player flips **2 of their cards** face-up (configurable: 0, 1, or 2) + +| Implementation | File | +|----------------|------| +| Deal 6 cards | `game.py:304-311` | +| Start discard pile | `game.py:313-317` | +| Initial flip phase | `game.py:326-352` | + +| Tests | File | +|-------|------| +| Initial flip 2 cards | `test_game.py:454-469` | +| Initial flip 0 skips phase | `test_game.py:471-478` | +| Game starts after all flip | `test_game.py:480-491` | + +--- ## Card Values -| Card | Points | -|------|--------| -| Ace | 1 | -| 2 | **-2** (negative!) | -| 3-10 | Face value | -| Jack | 10 | -| Queen | 10 | -| King | **0** | -| Joker | -2 | +| Card | Points | Notes | +|------|--------|-------| +| Ace | 1 | Low card | +| **2** | **-2** | Negative! Best non-special card | +| 3-10 | Face value | 3=3, 4=4, ..., 10=10 | +| Jack | 10 | Face card | +| Queen | 10 | Face card | +| **King** | **0** | Zero points | +| **Joker** | **-2** | Negative (requires `use_jokers` option) | + +### Card Value Quality Tiers + +| Tier | Cards | Strategy | +|------|-------|----------| +| **Excellent** | Joker (-2), 2 (-2) | Always keep, never pair | +| **Good** | King (0) | Safe, good for pairing | +| **Decent** | Ace (1) | Low risk | +| **Neutral** | 3, 4, 5 | Acceptable | +| **Bad** | 6, 7 | Replace when possible | +| **Terrible** | 8, 9, 10, J, Q | High priority to replace | + +| Implementation | File | +|----------------|------| +| DEFAULT_CARD_VALUES | `constants.py:3-18` | +| RANK_VALUES derivation | `game.py:40-41` | +| get_card_value() | `game.py:44-67` | + +| Tests | File | +|-------|------| +| Ace worth 1 | `test_game.py:29-30` | +| Two worth -2 | `test_game.py:32-33` | +| 3-10 face value | `test_game.py:35-43` | +| Jack worth 10 | `test_game.py:45-46` | +| Queen worth 10 | `test_game.py:48-49` | +| King worth 0 | `test_game.py:51-52` | +| Joker worth -2 | `test_game.py:54-55` | +| Card.value() method | `test_game.py:57-63` | +| Rank quality classification | `test_analyzer.py:47-74` | + +--- ## Column Pairing -**Critical rule:** If both cards in a column have the **same rank**, that column scores **0 points** regardless of the individual card values. +**Critical Rule:** If both cards in a column have the **same rank**, that column scores **0 points** regardless of the individual card values. -Example: +### Column Positions +``` +Column 0: positions (0, 3) +Column 1: positions (1, 4) +Column 2: positions (2, 5) +``` + +### Examples + +**Matched column:** ``` [K] [5] [7] K-K pair = 0 [K] [3] [9] 5+3 = 8, 7+9 = 16 Total: 0 + 8 + 16 = 24 ``` -**Note:** Paired 2s score 0 (not -4). The pair cancels out, it doesn't double the negative. +**All columns matched:** +``` +[A] [5] [K] All paired = 0 total +[A] [5] [K] +``` + +### Edge Case: Paired Negative Cards + +> **IMPORTANT:** Paired 2s score **0**, not -4. The pair **cancels** the value, it doesn't **double** it. + +This is a common source of bugs. When two 2s are paired: +- Individual values: -2 + -2 = -4 +- **Paired value: 0** (pair rule overrides) + +The same applies to paired Jokers (standard rules) - they score 0, not -4. + +| Implementation | File | +|----------------|------| +| Column pair detection | `game.py:158-178` | +| Pair cancels to 0 | `game.py:174-175` | + +| Tests | File | +|-------|------| +| Matching column scores 0 | `test_game.py:83-91` | +| All columns matched = 0 | `test_game.py:93-98` | +| No columns matched = sum | `test_game.py:100-106` | +| **Paired 2s = 0 (not -4)** | `test_game.py:108-115` | +| Unpaired negatives keep value | `test_game.py:117-124` | + +--- ## Turn Structure -On your turn: - ### 1. Draw Phase + Choose ONE: - Draw the **top card from the draw pile** (face-down deck) - Take the **top card from the discard pile** (face-up) @@ -62,7 +168,7 @@ Choose ONE: **If you drew from the DECK:** - **Swap:** Replace any card in your grid (old card goes to discard face-up) -- **Discard:** Put the drawn card on the discard pile and flip one face-down card +- **Discard:** Put the drawn card on the discard pile (optionally flip a face-down card) **If you took from the DISCARD PILE:** - **You MUST swap** - you cannot re-discard the same card @@ -74,32 +180,80 @@ Choose ONE: - You **cannot look** at a face-down card before deciding to replace it - When swapping a face-down card, reveal it only as it goes to discard +| Implementation | File | +|----------------|------| +| Draw from deck/discard | `game.py:354-384` | +| Swap card | `game.py:409-426` | +| Cannot re-discard from discard | `game.py:428-433`, `game.py:443-445` | +| Discard from deck draw | `game.py:435-460` | +| Flip after discard | `game.py:462-476` | + +| Tests | File | +|-------|------| +| Can draw from deck | `test_game.py:200-205` | +| Can draw from discard | `test_game.py:207-214` | +| Can discard deck draw | `test_game.py:216-221` | +| **Cannot discard discard draw** | `test_game.py:223-228` | +| Must swap discard draw | `test_game.py:230-238` | +| Swap makes card face-up | `test_game.py:240-247` | +| Cannot peek before swap | `test_game.py:249-256` | + +--- + ## Round End ### Triggering the Final Turn + When any player has **all 6 cards face-up**, the round enters "final turn" phase. ### Final Turn Phase + - Each **other player** gets exactly **one more turn** - The player who triggered final turn does NOT get another turn - After all players have had their final turn, the round ends ### Scoring + 1. All remaining face-down cards are revealed 2. Calculate each player's score (with column pairing) 3. Add round score to total score +| Implementation | File | +|----------------|------| +| Check all face-up | `game.py:478-483` | +| Final turn phase | `game.py:488-502` | +| End round scoring | `game.py:504-555` | + +| Tests | File | +|-------|------| +| Revealing all triggers final turn | `test_game.py:327-341` | +| Other players get final turn | `test_game.py:343-358` | +| Finisher doesn't get extra turn | `test_game.py:360-373` | +| All cards revealed at round end | `test_game.py:375-388` | + +--- + ## Winning - Standard game: **9 rounds** ("9 holes") - Player with the **lowest total score** wins - Optionally play 18 rounds for a longer game +| Implementation | File | +|----------------|------| +| Multi-round tracking | `game.py:557-567` | +| Total score accumulation | `game.py:548-549` | + +| Tests | File | +|-------|------| +| Next round resets hands | `test_game.py:398-418` | +| Scores accumulate across rounds | `test_game.py:420-444` | + --- -# House Rules (Optional) +# Part 2: House Rules -Our implementation supports these optional rule variations: +Our implementation supports these optional rule variations. All are **disabled by default**. ## Standard Options @@ -110,78 +264,641 @@ Our implementation supports these optional rule variations: | `knock_penalty` | +10 if you go out but don't have lowest score | Off | | `use_jokers` | Add Jokers to deck (-2 points each) | Off | +| Implementation | File | +|----------------|------| +| GameOptions dataclass | `game.py:200-222` | + ## Point Modifiers -| Option | Effect | -|--------|--------| -| `lucky_swing` | Single Joker worth **-5** (instead of two -2 Jokers) | -| `super_kings` | Kings worth **-2** (instead of 0) | -| `ten_penny` | 10s worth **1** (instead of 10) | +| Option | Effect | Standard Value | Modified Value | +|--------|--------|----------------|----------------| +| `lucky_swing` | Single Joker in deck | 2 Jokers @ -2 each | 1 Joker @ **-5** | +| `super_kings` | Kings are negative | King = 0 | King = **-2** | +| `ten_penny` | 10s are low | 10 = 10 | 10 = **1** | + +| Implementation | File | +|----------------|------| +| LUCKY_SWING_JOKER_VALUE | `constants.py:23` | +| SUPER_KINGS_VALUE | `constants.py:21` | +| TEN_PENNY_VALUE | `constants.py:22` | +| Value application | `game.py:58-66` | + +| Tests | File | +|-------|------| +| Super Kings -2 | `test_game.py:142-149` | +| Ten Penny | `test_game.py:151-158` | +| Lucky Swing Joker -5 | `test_game.py:160-173` | +| Lucky Swing single joker | `test_game.py:511-514` | ## Bonuses & Penalties -| Option | Effect | -|--------|--------| -| `knock_bonus` | First to reveal all cards gets **-5** bonus | -| `underdog_bonus` | Lowest scorer each round gets **-3** | -| `tied_shame` | Tying another player's score = **+5** penalty to both | -| `blackjack` | Exact score of 21 becomes **0** | +| Option | Effect | When Applied | +|--------|--------|--------------| +| `knock_bonus` | First to reveal all cards gets **-5** | Round end | +| `underdog_bonus` | Lowest scorer each round gets **-3** | Round end | +| `tied_shame` | Tying another player's score = **+5** penalty to both | Round end | +| `blackjack` | Exact score of 21 becomes **0** | Round end | +| `wolfpack` | 2 pairs of Jacks = **-5** bonus | Scoring | + +| Implementation | File | +|----------------|------| +| Blackjack 21->0 | `game.py:513-517` | +| Knock penalty | `game.py:519-525` | +| Knock bonus | `game.py:527-531` | +| Underdog bonus | `game.py:533-538` | +| Tied shame | `game.py:540-546` | +| Wolfpack | `game.py:180-182` | + +| Tests | File | +|-------|------| +| Blackjack 21 becomes 0 | `test_game.py:175-183` | +| House rules integration | `test_house_rules.py` (full file) | ## Special Rules | Option | Effect | |--------|--------| -| `eagle_eye` | Jokers worth **+2 unpaired**, **-4 paired** (spot the pair!) | +| `eagle_eye` | Jokers worth **+2 unpaired**, **-4 paired** (reward spotting pairs) | + +| Implementation | File | +|----------------|------| +| Eagle eye unpaired value | `game.py:60-61` | +| Eagle eye paired value | `game.py:169-173` | --- -# Game Theory Notes +# Part 3: AI Decision Making -## Expected Turn Count +## AI Profiles -With standard rules (2 initial flips): -- Start: 2 face-up, 4 face-down -- Each turn reveals 1 card (swap or discard+flip) -- **Minimum turns to go out:** 4 -- **Typical range:** 4-8 turns per player per round +8 distinct AI personalities with different play styles: -## Strategic Considerations +| Name | Style | Swap Threshold | Pair Hope | Aggression | +|------|-------|----------------|-----------|------------| +| Sofia | Calculated & Patient | 4 | 0.2 | 0.2 | +| Maya | Aggressive Closer | 6 | 0.4 | 0.85 | +| Priya | Pair Hunter | 7 | 0.8 | 0.5 | +| Marcus | Steady Eddie | 5 | 0.35 | 0.4 | +| Kenji | Risk Taker | 8 | 0.7 | 0.75 | +| Diego | Chaotic Gambler | 6 | 0.5 | 0.6 | +| River | Adaptive Strategist | 5 | 0.45 | 0.55 | +| Sage | Sneaky Finisher | 5 | 0.3 | 0.9 | -### Good Cards (keep these) -- **Jokers** (-2 or -5): Best cards in the game -- **2s** (-2): Second best, but don't pair them! -- **Kings** (0): Safe, good for pairing +| Implementation | File | +|----------------|------| +| CPUProfile dataclass | `ai.py:164-182` | +| CPU_PROFILES list | `ai.py:186-253` | + +## Key AI Decision Functions + +### should_take_discard() + +Decides whether to take from discard pile or draw from deck. + +**Logic priority:** +1. Always take Jokers (and pair if Eagle Eye) +2. Always take Kings +3. Take 10s if ten_penny enabled +4. Take cards that complete a column pair (**except negative cards**) +5. Take low cards based on game phase threshold +6. Consider end-game pressure +7. Take if we have worse visible cards + +| Implementation | File | +|----------------|------| +| should_take_discard() | `ai.py:333-412` | +| Negative card pair avoidance | `ai.py:365-374` | + +| Tests | File | +|-------|------| +| Maya doesn't take 10 with good hand | `test_maya_bug.py:52-83` | +| Unpredictability doesn't take bad cards | `test_maya_bug.py:85-116` | +| Pair potential respected | `test_maya_bug.py:289-315` | + +### choose_swap_or_discard() + +Decides whether to swap the drawn card into hand or discard it. + +**Logic priority:** +1. Eagle Eye: Pair Jokers if visible match exists +2. Check for column pair opportunity (**except negative cards**) +3. Find best swap among BAD face-up cards (positive value) +4. Consider Blackjack (21) pursuit +5. Swap excellent cards into face-down positions +6. Apply profile-based thresholds + +**Critical:** When placing cards into face-down positions, the AI must avoid creating wasteful pairs with visible negative cards. + +| Implementation | File | +|----------------|------| +| choose_swap_or_discard() | `ai.py:414-536` | +| Negative card pair avoidance | `ai.py:441-446` | + +| Tests | File | +|-------|------| +| Don't discard excellent cards | `test_maya_bug.py:179-209` | +| Full Maya bug scenario | `test_maya_bug.py:211-254` | + +--- + +# Part 4: Edge Cases & Known Issues + +## Edge Case: Pairing Negative Cards + +**Problem:** Pairing 2s or Jokers wastes their negative value. +- Unpaired 2: contributes -2 to score +- Paired 2s: contribute 0 to score (lost 2 points!) + +**AI Safeguards:** +1. `should_take_discard()`: Only considers pairing if `discard_value > 0` +2. `choose_swap_or_discard()`: Sets `should_pair = drawn_value > 0` +3. `filter_bad_pair_positions()`: Filters out positions that would create wasteful pairs when placing negative cards into face-down positions + +| Implementation | File | +|----------------|------| +| get_column_partner_position() | `ai.py:163-168` | +| filter_bad_pair_positions() | `ai.py:171-213` | +| Applied in choose_swap_or_discard | `ai.py:517`, `ai.py:538` | +| Applied in forced swap | `ai.py:711-713` | + +| Tests | File | +|-------|------| +| Paired 2s = 0 (game logic) | `test_game.py:108-115` | +| AI avoids pairing logic | `ai.py:365-374`, `ai.py:441-446` | +| Filter with visible two | `test_maya_bug.py:320-347` | +| Filter allows positive pairs | `test_maya_bug.py:349-371` | +| Choose swap avoids 2 pairs | `test_maya_bug.py:373-401` | +| Forced swap avoids 2 pairs | `test_maya_bug.py:403-425` | +| Fallback when all bad | `test_maya_bug.py:427-451` | + +## Edge Case: Forced Swap from Discard + +When drawing from discard pile and `choose_swap_or_discard()` returns `None` (discard), the AI is forced to swap anyway. The fallback picks randomly from face-down positions, or finds the worst face-up card. + +| Implementation | File | +|----------------|------| +| Forced swap fallback | `ai.py:665-686` | + +| Tests | File | +|-------|------| +| Forced swap uses house rules | `test_maya_bug.py:143-177` | +| All face-up finds worst | `test_maya_bug.py:260-287` | + +## Edge Case: Deck Exhaustion + +When the deck is empty, the discard pile (except top card) is reshuffled back into the deck. + +| Implementation | File | +|----------------|------| +| Reshuffle discard pile | `game.py:386-407` | + +## Edge Case: Empty Discard Pile + +Cannot draw from empty discard pile. + +| Tests | File | +|-------|------| +| Empty discard returns None | `test_game.py:558-569` | + +--- + +# Part 5: Test Coverage Summary + +## Test Files + +| File | Tests | Focus | +|------|-------|-------| +| `test_game.py` | 44 | Core game rules | +| `test_house_rules.py` | 10+ | House rule integration | +| `test_analyzer.py` | 18 | AI decision evaluation | +| `test_maya_bug.py` | 18 | Bug regression & AI edge cases | + +**Total: 83+ tests** + +## Coverage by Category + +| Category | Tests | Files | Status | +|----------|-------|-------|--------| +| Card Values | 8 | `test_game.py`, `test_analyzer.py` | Complete | +| Column Pairing | 5 | `test_game.py` | Complete | +| House Rules Scoring | 4 | `test_game.py` | Complete | +| Draw/Discard Mechanics | 7 | `test_game.py` | Complete | +| Turn Flow | 4 | `test_game.py` | Complete | +| Round End | 4 | `test_game.py` | Complete | +| Multi-Round | 2 | `test_game.py` | Complete | +| Initial Flip | 3 | `test_game.py` | Complete | +| Deck Management | 4 | `test_game.py` | Complete | +| Edge Cases | 3 | `test_game.py` | Complete | +| Take Discard Evaluation | 6 | `test_analyzer.py` | Complete | +| Swap Evaluation | 6 | `test_analyzer.py` | Complete | +| House Rules Evaluation | 2 | `test_analyzer.py` | Complete | +| Maya Bug Regression | 6 | `test_maya_bug.py` | Complete | +| AI Edge Cases | 3 | `test_maya_bug.py` | Complete | +| Bad Pair Avoidance | 5 | `test_maya_bug.py` | Complete | + +## Test Plan: Critical Paths + +### Game State Transitions + +``` +WAITING -> INITIAL_FLIP -> PLAYING -> FINAL_TURN -> ROUND_OVER -> GAME_OVER + | ^ + v (initial_flips=0) | + +-------------------------+ +``` + +| Transition | Trigger | Tests | +|------------|---------|-------| +| WAITING -> INITIAL_FLIP | start_game() | `test_game.py:454-469` | +| WAITING -> PLAYING | start_game(initial_flips=0) | `test_game.py:471-478` | +| INITIAL_FLIP -> PLAYING | All players flip | `test_game.py:480-491` | +| PLAYING -> FINAL_TURN | Player all face-up | `test_game.py:327-341` | +| FINAL_TURN -> ROUND_OVER | All final turns done | `test_game.py:343-358` | +| ROUND_OVER -> PLAYING | start_next_round() | `test_game.py:398-418` | +| ROUND_OVER -> GAME_OVER | Final round complete | `test_game.py:420-444` | + +### AI Decision Tree + +``` +Draw Phase: + ├── should_take_discard() returns True + │ └── Draw from discard pile + │ └── MUST swap (can_discard_drawn=False) + └── should_take_discard() returns False + └── Draw from deck + ├── choose_swap_or_discard() returns position + │ └── Swap at position + └── choose_swap_or_discard() returns None + └── Discard drawn card + └── flip_on_discard? -> choose_flip_after_discard() +``` + +| Decision Point | Tests | +|----------------|-------| +| Take Joker/King from discard | `test_analyzer.py:96-114` | +| Don't take bad cards | `test_maya_bug.py:52-116` | +| Swap excellent cards | `test_maya_bug.py:179-209` | +| Avoid pairing negatives | `test_maya_bug.py:320-451` | +| Forced swap from discard | `test_maya_bug.py:143-177`, `test_maya_bug.py:403-425` | + +### Scoring Edge Cases + +| Scenario | Expected | Test | +|----------|----------|------| +| Paired 2s | 0 (not -4) | `test_game.py:108-115` | +| Paired Jokers (standard) | 0 | Implicit | +| Paired Jokers (eagle_eye) | -4 | `game.py:169-173` | +| Unpaired negative cards | -2 each | `test_game.py:117-124` | +| All columns matched | 0 total | `test_game.py:93-98` | +| Blackjack (21) | 0 | `test_game.py:175-183` | + +## Running Tests + +```bash +# Run all tests +cd server && python -m pytest -v + +# Run specific test file +python -m pytest test_game.py -v + +# Run specific test class +python -m pytest test_game.py::TestCardValues -v + +# Run with coverage +python -m pytest --cov=. --cov-report=html + +# Run tests matching pattern +python -m pytest -k "pair" -v +``` + +## Test Quality Checklist + +- [x] All card values verified against RULES.md +- [x] Column pairing logic tested (including negatives) +- [x] House rules tested individually +- [x] Draw/discard constraints enforced +- [x] Turn flow and player validation +- [x] Round end and final turn logic +- [x] Multi-round score accumulation +- [x] AI decision quality evaluation +- [x] Bug regression tests for Maya bug +- [x] AI avoids wasteful negative card pairs + +## Disadvantageous Moves (AI Quality Metrics) + +### Definition: "Dumb Moves" + +Moves that are objectively suboptimal and should occur at **minimal background noise level** (< 1% of opportunities). + +| Move Type | Severity | Expected Prevalence | Test Coverage | +|-----------|----------|---------------------|---------------| +| **Discarding Joker/2** | Blunder | 0% | `test_maya_bug.py:179-209` | +| **Discarding King** | Mistake | 0% | `test_analyzer.py:183-192` | +| **Taking 10/J/Q without pair** | Blunder | 0% | `test_maya_bug.py:52-116` | +| **Pairing negative cards** | Mistake | 0% | `test_maya_bug.py:373-401` | +| **Swapping good card for bad** | Mistake | 0% | `test_analyzer.py:219-237` | + +### Definition: "Questionable Moves" + +Moves that may be suboptimal but have legitimate strategic reasons. Should be < 5% of opportunities. + +| Move Type | When Acceptable | Monitoring | +|-----------|-----------------|------------| +| Not taking low card (3-5) | Pair hunting, early game | Profile-based | +| Discarding medium card (4-6) | Full hand, pair potential | Context check | +| Going out with high score | Pressure, knock_bonus | Threshold based | + +### AI Quality Assertions + +These assertions should pass when running extended simulations: + +```python +# In test suite or simulation +def test_ai_quality_metrics(): + """Run N games and verify dumb moves are at noise level.""" + stats = run_simulation(games=1000) + + # ZERO tolerance blunders + assert stats.discarded_jokers == 0 + assert stats.discarded_twos == 0 + assert stats.took_bad_card_without_pair == 0 + assert stats.paired_negative_cards == 0 + + # Near-zero tolerance mistakes + assert stats.discarded_kings < stats.total_turns * 0.001 # < 0.1% + assert stats.swapped_good_for_bad < stats.total_turns * 0.001 + + # Acceptable variance + assert stats.questionable_moves < stats.total_turns * 0.05 # < 5% +``` + +### Tracking Implementation + +Decision quality should be logged for analysis: + +| Field | Description | +|-------|-------------| +| `decision_type` | take_discard, swap, discard, flip | +| `decision_quality` | OPTIMAL, GOOD, QUESTIONABLE, MISTAKE, BLUNDER | +| `expected_value` | EV calculation for the decision | +| `profile_name` | AI personality that made decision | +| `game_phase` | early, mid, late | + +See `game_analyzer.py` for decision evaluation logic. + +## Recommended Additional Tests + +| Area | Description | Priority | +|------|-------------|----------| +| AI Quality Metrics | Simulation-based dumb move detection | **Critical** | +| WebSocket | Integration tests for real-time communication | High | +| Concurrent games | Multiple simultaneous rooms | Medium | +| Deck exhaustion | Reshuffle when deck empty | Medium | +| All house rule combos | Interaction between rules | Medium | +| AI personality variance | Verify distinct behaviors | Low | +| Performance | Load testing with many players | Low | + +--- + +# Part 6: Strategic Notes + +## Card Priority (for AI and Players) + +### Always Keep +- **Jokers** (-2 or -5): Best cards in game +- **2s** (-2): Second best, but **don't pair them!** + +### Keep When Possible +- **Kings** (0): Safe, excellent for pairing - **Aces** (1): Low risk -### Bad Cards (replace these) -- **10, J, Q** (10 points): Worst cards -- **8, 9** (8-9 points): High priority to replace +### Replace When Possible +- **6, 7** (6-7 points): Moderate priority +- **8, 9** (8-9 points): High priority +- **10, J, Q** (10 points): Highest priority + +## Pairing Strategy -### Pairing Strategy - Pairing is powerful - column score goes to 0 -- **Don't pair negative cards** - you lose the negative benefit +- **Never pair negative cards** (2s, Jokers) - you lose the negative benefit - Target pairs with mid-value cards (3-7) for maximum gain +- High-value pairs (10, J, Q) are valuable (+20 point swing) -### When to Go Out -- Go out with **score ≤ 10** when confident you're lowest +## When to Go Out + +- Go out with **score <= 10** when confident you're lowest - Consider opponent visible cards before going out early - With `knock_penalty`, be careful - +10 hurts if you're wrong +- With `knock_bonus`, more incentive to finish first --- -# Test Coverage +# Part 7: Configuration -The game engine has comprehensive test coverage in `test_game.py`: +## Configuration Files -- **Card Values:** All 13 ranks verified -- **Column Pairing:** Matching, non-matching, negative card edge cases -- **House Rules:** All scoring modifiers tested -- **Draw/Discard:** Deck draws, discard draws, must-swap rule -- **Turn Flow:** Turn advancement, wrap-around, player validation -- **Round End:** Final turn triggering, one-more-turn logic -- **Multi-Round:** Score accumulation, hand reset +| File | Purpose | +|------|---------| +| `pyproject.toml` | Project metadata, dependencies, tool config | +| `server/config.py` | Centralized configuration loader | +| `server/constants.py` | Card values and game constants | +| `.env.example` | Environment variable documentation | +| `.env` | Local environment overrides (not committed) | + +## Environment Variables + +Configuration precedence (highest to lowest): +1. Environment variables +2. `.env` file +3. Default values in code + +### Server Settings + +| Variable | Default | Description | +|----------|---------|-------------| +| `HOST` | `0.0.0.0` | Host to bind to | +| `PORT` | `8000` | Port to listen on | +| `DEBUG` | `false` | Enable debug mode | +| `LOG_LEVEL` | `INFO` | Logging level | +| `DATABASE_URL` | `sqlite:///games.db` | Database connection | + +### Room Settings + +| Variable | Default | Description | +|----------|---------|-------------| +| `MAX_PLAYERS_PER_ROOM` | `6` | Max players per game | +| `ROOM_TIMEOUT_MINUTES` | `60` | Inactive room cleanup | +| `ROOM_CODE_LENGTH` | `4` | Room code length | + +### Game Defaults + +| Variable | Default | Description | +|----------|---------|-------------| +| `DEFAULT_ROUNDS` | `9` | Rounds per game | +| `DEFAULT_INITIAL_FLIPS` | `2` | Cards to flip at start | +| `DEFAULT_USE_JOKERS` | `false` | Enable jokers | +| `DEFAULT_FLIP_ON_DISCARD` | `false` | Flip after discard | + +### Security + +| Variable | Default | Description | +|----------|---------|-------------| +| `SECRET_KEY` | (empty) | Secret key for sessions | +| `INVITE_ONLY` | `false` | Require invitation to register | +| `ADMIN_EMAILS` | (empty) | Comma-separated admin emails | + +## Running the Server -Run tests with: ```bash -pytest test_game.py -v +# Development (with auto-reload) +DEBUG=true python server/main.py + +# Production +PORT=8080 LOG_LEVEL=WARNING python server/main.py + +# With .env file +cp .env.example .env +# Edit .env as needed +python server/main.py + +# Using uvicorn directly +uvicorn server.main:app --host 0.0.0.0 --port 8000 ``` + +--- + +# Part 8: Authentication + +## Overview + +The authentication system supports: +- User accounts stored in SQLite (`users` table) +- Admin accounts that can manage other users +- Invite codes (or room codes) for registration +- Session-based authentication with bearer tokens + +## First-Time Setup + +When the server starts with no admin accounts: +1. A default `admin` account is created (or accounts for each email in `ADMIN_EMAILS`) +2. The admin account has **no password** initially +3. On first login attempt, use `/api/auth/setup-password` to set the password + +```bash +# Check if admin needs setup +curl http://localhost:8000/api/auth/check-setup/admin + +# Set admin password (first time only) +curl -X POST http://localhost:8000/api/auth/setup-password \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "new_password": "your-secure-password"}' +``` + +## API Endpoints + +### Public Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/auth/check-setup/{username}` | GET | Check if user needs password setup | +| `/api/auth/setup-password` | POST | Set password (first login only) | +| `/api/auth/login` | POST | Login with username/password | +| `/api/auth/register` | POST | Register with invite code | +| `/api/auth/logout` | POST | Logout current session | + +### Authenticated Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/auth/me` | GET | Get current user info | +| `/api/auth/password` | PUT | Change own password | + +### Admin Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/admin/users` | GET | List all users | +| `/api/admin/users/{id}` | GET | Get user by ID | +| `/api/admin/users/{id}` | PUT | Update user | +| `/api/admin/users/{id}/password` | PUT | Change user password | +| `/api/admin/users/{id}` | DELETE | Deactivate user | +| `/api/admin/invites` | POST | Create invite code | +| `/api/admin/invites` | GET | List invite codes | +| `/api/admin/invites/{code}` | DELETE | Deactivate invite code | + +## Registration Flow + +1. User obtains an invite code (from admin) or a room code (from active game) +2. User calls `/api/auth/register` with username, password, and invite code +3. If valid, account is created and session token is returned + +```bash +# Register with room code +curl -X POST http://localhost:8000/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{"username": "player1", "password": "pass123", "invite_code": "ABCD"}' +``` + +## Authentication Header + +After login, include the token in requests: + +``` +Authorization: Bearer +``` + +## 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* diff --git a/server/ai.py b/server/ai.py index e8860e9..e7376e2 100644 --- a/server/ai.py +++ b/server/ai.py @@ -1,6 +1,7 @@ """AI personalities for CPU players in Golf.""" import logging +import os import random from dataclasses import dataclass from typing import Optional @@ -9,6 +10,29 @@ from enum import Enum from game import Card, Player, Game, GamePhase, GameOptions, RANK_VALUES, Rank, get_card_value +# Debug logging configuration +# Set AI_DEBUG=1 environment variable to enable detailed AI decision logging +AI_DEBUG = os.environ.get("AI_DEBUG", "0") == "1" + +# Create a dedicated logger for AI decisions +ai_logger = logging.getLogger("golf.ai") +if AI_DEBUG: + ai_logger.setLevel(logging.DEBUG) + # Add console handler if not already present + if not ai_logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter( + "%(asctime)s [AI] %(message)s", datefmt="%H:%M:%S" + )) + ai_logger.addHandler(handler) + + +def ai_log(message: str): + """Log AI decision info when AI_DEBUG is enabled.""" + if AI_DEBUG: + ai_logger.debug(message) + + # Alias for backwards compatibility - use the centralized function from game.py def get_ai_card_value(card: Card, options: GameOptions) -> int: """Get card value with house rules applied for AI decisions. @@ -161,6 +185,62 @@ def has_worse_visible_card(player: Player, card_value: int, options: GameOptions return False +def get_column_partner_position(pos: int) -> int: + """Get the column partner position for a given position. + + Column pairs: (0,3), (1,4), (2,5) + """ + return (pos + 3) % 6 if pos < 3 else pos - 3 + + +def filter_bad_pair_positions( + positions: list[int], + drawn_card: Card, + player: Player, + options: GameOptions +) -> list[int]: + """Filter out positions that would create wasteful pairs with negative cards. + + When placing a card (especially negative value cards like 2s or Jokers), + we should avoid positions where the column partner is a visible card of + the same rank - pairing negative cards wastes their value. + + Args: + positions: List of candidate positions + drawn_card: The card we're placing + player: The player's hand + options: Game options for house rules + + Returns: + Filtered list excluding bad pair positions. If all positions are bad, + returns the original list (we have to place somewhere). + """ + drawn_value = get_ai_card_value(drawn_card, options) + + # Only filter if the drawn card has negative value (2s, Jokers, super_kings Kings) + # Pairing positive cards is fine - it turns their value to 0 + if drawn_value >= 0: + return positions + + # Exception: Eagle Eye makes pairing Jokers GOOD (-4 instead of 0) + if options.eagle_eye and drawn_card.rank == Rank.JOKER: + return positions + + filtered = [] + for pos in positions: + partner_pos = get_column_partner_position(pos) + partner = player.cards[partner_pos] + + # If partner is face-up and same rank, this would create a wasteful pair + if partner.face_up and partner.rank == drawn_card.rank: + continue # Skip this position + + filtered.append(pos) + + # If all positions were filtered out, return original (must place somewhere) + return filtered if filtered else positions + + @dataclass class CPUProfile: """Pre-defined CPU player profile with personality traits.""" @@ -340,6 +420,40 @@ class GolfAI: options = game.options discard_value = get_ai_card_value(discard_card, options) + ai_log(f"--- {profile.name} considering discard: {discard_card.rank.value}{discard_card.suit.value} (value={discard_value}) ---") + + # SAFEGUARD: If we have only 1 face-down card, taking from discard + # forces us to swap and go out. Check if that would be acceptable. + face_down = [i for i, c in enumerate(player.cards) if not c.face_up] + if len(face_down) == 1: + # Calculate projected score if we swap into the last face-down position + projected_score = 0 + for i, c in enumerate(player.cards): + if i == face_down[0]: + projected_score += discard_value + elif c.face_up: + projected_score += get_ai_card_value(c, options) + + # Apply column pair cancellation + for col in range(3): + top_idx, bot_idx = col, col + 3 + top_card = discard_card if top_idx == face_down[0] else player.cards[top_idx] + bot_card = discard_card if bot_idx == face_down[0] else player.cards[bot_idx] + if top_card.rank == bot_card.rank: + top_val = discard_value if top_idx == face_down[0] else get_ai_card_value(player.cards[top_idx], options) + bot_val = discard_value if bot_idx == face_down[0] else get_ai_card_value(player.cards[bot_idx], options) + projected_score -= (top_val + bot_val) + + # Don't take if score would be terrible + max_acceptable = 18 if profile.aggression > 0.6 else (16 if profile.aggression > 0.3 else 14) + ai_log(f" Go-out check: projected={projected_score}, max_acceptable={max_acceptable}") + if projected_score > max_acceptable: + # Exception: still take if it's an excellent card (Joker, 2, King, Ace) + # and we have a visible bad card to replace instead + if discard_value >= 0 and discard_card.rank not in (Rank.ACE, Rank.TWO, Rank.KING, Rank.JOKER): + ai_log(f" >> REJECT: would force go-out with {projected_score} pts") + return False # Don't take - would force bad go-out + # Unpredictable players occasionally make random choice # BUT only for reasonable cards (value <= 5) - never randomly take bad cards if random.random() < profile.unpredictability: @@ -352,14 +466,18 @@ class GolfAI: if options.eagle_eye: for card in player.cards: if card.face_up and card.rank == Rank.JOKER: + ai_log(f" >> TAKE: Joker for Eagle Eye pair") return True + ai_log(f" >> TAKE: Joker (always take)") return True if discard_card.rank == Rank.KING: + ai_log(f" >> TAKE: King (always take)") return True # Auto-take 10s when ten_penny enabled (they're worth 1) if discard_card.rank == Rank.TEN and options.ten_penny: + ai_log(f" >> TAKE: 10 (ten_penny rule)") return True # Take card if it could make a column pair (but NOT for negative value cards) @@ -371,6 +489,7 @@ class GolfAI: # Direct rank match if card.face_up and card.rank == discard_card.rank and not pair_card.face_up: + ai_log(f" >> TAKE: can pair with visible {card.rank.value} at pos {i}") return True # Take low cards (using house rule adjusted values) @@ -379,6 +498,7 @@ class GolfAI: base_threshold = {'early': 2, 'mid': 3, 'late': 4}.get(phase, 2) if discard_value <= base_threshold: + ai_log(f" >> TAKE: low card (value {discard_value} <= {base_threshold} threshold for {phase} game)") return True # Calculate end-game pressure from opponents close to going out @@ -395,6 +515,7 @@ class GolfAI: # Only take if we have hidden cards that could be worse my_hidden = sum(1 for c in player.cards if not c.face_up) if my_hidden > 0: + ai_log(f" >> TAKE: pressure={pressure:.2f}, threshold={pressure_threshold}") return True # Check if we have cards worse than the discard @@ -407,133 +528,281 @@ class GolfAI: # Sanity check: only take if we actually have something worse to replace # This prevents taking a bad card when all visible cards are better if has_worse_visible_card(player, discard_value, options): + ai_log(f" >> TAKE: have worse visible card ({worst_visible})") return True + ai_log(f" >> PASS: drawing from deck instead") return False + @staticmethod + def calculate_swap_score( + pos: int, + drawn_card: Card, + drawn_value: int, + player: Player, + options: GameOptions, + game: Game, + profile: CPUProfile + ) -> float: + """ + Calculate a score for swapping into a specific position. + Higher score = better swap. Weighs all incentives: + - Pair bonus (highest priority for positive cards) + - Point gain from replacement + - Reveal bonus for hidden cards + - Go-out safety check + + Personality traits affect weights: + - pair_hope: higher = values pairing more, lower = prefers spreading + - aggression: higher = more willing to go out, take risks + - swap_threshold: affects how picky about card values + """ + current_card = player.cards[pos] + partner_pos = get_column_partner_position(pos) + partner_card = player.cards[partner_pos] + + score = 0.0 + + # Personality-based weight modifiers + # pair_hope: 0.0-1.0, affects how much we value pairing vs spreading + pair_weight = 1.0 + profile.pair_hope # Range: 1.0 to 2.0 + spread_weight = 2.0 - profile.pair_hope # Range: 1.0 to 2.0 (inverse) + + # 1. PAIR BONUS - Creating a pair + # pair_hope affects how much we value this + if partner_card.face_up and partner_card.rank == drawn_card.rank: + partner_value = get_ai_card_value(partner_card, options) + + if drawn_value >= 0: + # Good pair! Both cards cancel to 0 + pair_bonus = drawn_value + partner_value + score += pair_bonus * pair_weight # Pair hunters value this more + else: + # Pairing negative cards - usually bad + if options.eagle_eye and drawn_card.rank == Rank.JOKER: + score += 8 * pair_weight # Eagle Eye Joker pairs + else: + # Penalty, but pair hunters might still do it + penalty = abs(drawn_value) * 2 * (2.0 - profile.pair_hope) + score -= penalty + + # 1b. SPREAD BONUS - Not pairing good cards (spreading them out) + # Players with low pair_hope prefer spreading aces/2s across columns + if not partner_card.face_up or partner_card.rank != drawn_card.rank: + if drawn_value <= 1: # Excellent cards (K, 2, A, Joker) + # Small bonus for spreading - scales with spread preference + score += spread_weight * 0.5 + + # 2. POINT GAIN - Direct value improvement + if current_card.face_up: + current_value = get_ai_card_value(current_card, options) + point_gain = current_value - drawn_value + score += point_gain + else: + # Hidden card - expected value ~4.5 + expected_hidden = 4.5 + point_gain = expected_hidden - drawn_value + # Conservative players (low swap_threshold) discount uncertain gains more + discount = 0.5 + (profile.swap_threshold / 16) # Range: 0.5 to 1.0 + score += point_gain * discount + + # 3. REVEAL BONUS - Value of revealing hidden cards + # More aggressive players want to reveal faster to go out + if not current_card.face_up: + hidden_count = sum(1 for c in player.cards if not c.face_up) + reveal_bonus = min(hidden_count, 4) + + # Aggressive players get bigger reveal bonus (want to go out faster) + aggression_multiplier = 0.8 + profile.aggression * 0.4 # Range: 0.8 to 1.2 + + # Scale by card quality + if drawn_value <= 0: # Excellent + score += reveal_bonus * 1.2 * aggression_multiplier + elif drawn_value == 1: # Great + score += reveal_bonus * 1.0 * aggression_multiplier + elif drawn_value <= 4: # Good + score += reveal_bonus * 0.6 * aggression_multiplier + elif drawn_value <= 6: # Medium + score += reveal_bonus * 0.3 * aggression_multiplier + # Bad cards: no reveal bonus + + # 4. FUTURE PAIR POTENTIAL + # Pair hunters value positions where both cards are hidden + if not current_card.face_up and not partner_card.face_up: + pair_viability = get_pair_viability(drawn_card.rank, game) + score += pair_viability * pair_weight * 0.5 + + # 5. GO-OUT SAFETY - Penalty for going out with bad score + face_down_positions = [i for i, c in enumerate(player.cards) if not c.face_up] + if len(face_down_positions) == 1 and pos == face_down_positions[0]: + projected_score = drawn_value + for i, c in enumerate(player.cards): + if i != pos and c.face_up: + projected_score += get_ai_card_value(c, options) + + # Apply pair cancellation + for col in range(3): + top_idx, bot_idx = col, col + 3 + top_card = drawn_card if top_idx == pos else player.cards[top_idx] + bot_card = drawn_card if bot_idx == pos else player.cards[bot_idx] + if top_card.rank == bot_card.rank: + top_val = drawn_value if top_idx == pos else get_ai_card_value(player.cards[top_idx], options) + bot_val = drawn_value if bot_idx == pos else get_ai_card_value(player.cards[bot_idx], options) + projected_score -= (top_val + bot_val) + + # Aggressive players accept higher scores when going out + max_acceptable = 12 + int(profile.aggression * 8) # Range: 12 to 20 + if projected_score > max_acceptable: + score -= 100 + + return score + @staticmethod def choose_swap_or_discard(drawn_card: Card, player: Player, profile: CPUProfile, game: Game) -> Optional[int]: """ Decide whether to swap the drawn card or discard. Returns position to swap with, or None to discard. + + Uses a unified scoring system that weighs all incentives: + - Pair creation (best for positive cards, bad for negative) + - Point gain from replacement + - Revealing hidden cards (catching up, information) + - Safety (don't go out with terrible score) """ options = game.options drawn_value = get_ai_card_value(drawn_card, options) - # Unpredictable players occasionally make surprising play - # BUT never discard excellent cards (Jokers, 2s, Kings, Aces) + ai_log(f"=== {profile.name} deciding: drew {drawn_card.rank.value}{drawn_card.suit.value} (value={drawn_value}) ===") + ai_log(f" Personality: pair_hope={profile.pair_hope:.2f}, aggression={profile.aggression:.2f}, " + f"swap_threshold={profile.swap_threshold}, unpredictability={profile.unpredictability:.2f}") + + # Log current hand state + hand_str = " ".join( + f"[{i}:{c.rank.value if c.face_up else '?'}]" for i, c in enumerate(player.cards) + ) + ai_log(f" Hand: {hand_str}") + + # Unpredictable players occasionally make surprising plays + # But never discard excellent cards (Jokers, 2s, Kings, Aces) if random.random() < profile.unpredictability: - if drawn_value > 1: # Only be unpredictable with non-excellent cards + if drawn_value > 1: face_down = [i for i, c in enumerate(player.cards) if not c.face_up] if face_down and random.random() < 0.5: - return random.choice(face_down) + choice = random.choice(face_down) + ai_log(f" >> UNPREDICTABLE: randomly chose position {choice}") + return choice - # Eagle Eye: If drawn card is Joker, look for existing visible Joker to pair - if options.eagle_eye and drawn_card.rank == Rank.JOKER: - for i, card in enumerate(player.cards): - if card.face_up and card.rank == Rank.JOKER: - pair_pos = (i + 3) % 6 if i < 3 else i - 3 - if not player.cards[pair_pos].face_up: - return pair_pos + # Calculate score for each position + position_scores: list[tuple[int, float]] = [] + for pos in range(6): + score = GolfAI.calculate_swap_score( + pos, drawn_card, drawn_value, player, options, game, profile + ) + position_scores.append((pos, score)) - # Check for column pair opportunity first - # But DON'T pair negative value cards (2s, Jokers) - keeping them unpaired is better! - # Exception: Eagle Eye makes pairing Jokers GOOD (doubled negative) - should_pair = drawn_value > 0 - if options.eagle_eye and drawn_card.rank == Rank.JOKER: - should_pair = True + # Log all scores + ai_log(f" Position scores:") + for pos, score in position_scores: + card = player.cards[pos] + partner_pos = get_column_partner_position(pos) + partner = player.cards[partner_pos] + card_str = card.rank.value if card.face_up else "?" + partner_str = partner.rank.value if partner.face_up else "?" + pair_indicator = " [PAIR]" if partner.face_up and partner.rank == drawn_card.rank else "" + reveal_indicator = " [REVEAL]" if not card.face_up else "" + ai_log(f" pos {pos} ({card_str}, partner={partner_str}): {score:+.2f}{pair_indicator}{reveal_indicator}") - if should_pair: - for i, card in enumerate(player.cards): - pair_pos = (i + 3) % 6 if i < 3 else i - 3 - pair_card = player.cards[pair_pos] + # Filter to positive scores only + positive_scores = [(p, s) for p, s in position_scores if s > 0] - # Direct rank match - if card.face_up and card.rank == drawn_card.rank and not pair_card.face_up: - return pair_pos + best_pos: Optional[int] = None + best_score = 0.0 - if pair_card.face_up and pair_card.rank == drawn_card.rank and not card.face_up: - return i + if positive_scores: + # Sort by score descending + positive_scores.sort(key=lambda x: x[1], reverse=True) + best_pos, best_score = positive_scores[0] - # Find best swap among face-up cards that are BAD (positive value) - # Don't swap good cards (Kings, 2s, etc.) just for marginal gains - - # we want to keep good cards and put new good cards into face-down positions - best_swap: Optional[int] = None - best_gain = 0 + # PERSONALITY TIE-BREAKER: When top options are close, let personality decide + close_threshold = 2.0 # Options within 2 points are "close" + close_options = [(p, s) for p, s in positive_scores if s >= best_score - close_threshold] - for i, card in enumerate(player.cards): - if card.face_up: - card_value = get_ai_card_value(card, options) - # Only consider replacing cards that are actually bad (positive value) - if card_value > 0: - gain = card_value - drawn_value - if gain > best_gain: - best_gain = gain - best_swap = i + if len(close_options) > 1: + ai_log(f" TIE-BREAKER: {len(close_options)} options within {close_threshold} pts of best ({best_score:.2f})") + original_best = best_pos - # Swap if we gain points (conservative players need more gain) - min_gain = 2 if profile.swap_threshold <= 4 else 1 - if best_gain >= min_gain: - return best_swap + # Multiple close options - personality decides + # Categorize each option + for pos, score in close_options: + partner_pos = get_column_partner_position(pos) + partner_card = player.cards[partner_pos] + is_pair_move = partner_card.face_up and partner_card.rank == drawn_card.rank + is_reveal_move = not player.cards[pos].face_up - # Blackjack: Check if any swap would result in exactly 21 - if options.blackjack: + # Pair hunters prefer pair moves + if is_pair_move and profile.pair_hope > 0.6: + ai_log(f" >> PAIR_HOPE ({profile.pair_hope:.2f}): chose pair move at pos {pos}") + best_pos = pos + break + # Aggressive players prefer reveal moves (to go out faster) + if is_reveal_move and profile.aggression > 0.7: + ai_log(f" >> AGGRESSION ({profile.aggression:.2f}): chose reveal move at pos {pos}") + best_pos = pos + break + # Conservative players prefer safe visible card replacements + if not is_reveal_move and profile.swap_threshold <= 4: + ai_log(f" >> CONSERVATIVE (threshold={profile.swap_threshold}): chose safe move at pos {pos}") + best_pos = pos + break + + # If still tied, add small random factor based on unpredictability + if profile.unpredictability > 0.1 and random.random() < profile.unpredictability: + best_pos = random.choice([p for p, s in close_options]) + ai_log(f" >> RANDOM (unpredictability={profile.unpredictability:.2f}): chose pos {best_pos}") + + if best_pos != original_best: + ai_log(f" Tie-breaker changed choice: {original_best} -> {best_pos}") + + # Blackjack special case: chase exactly 21 + if options.blackjack and best_pos is None: current_score = player.calculate_score() - if current_score >= 15: # Only chase 21 from high scores + if current_score >= 15: for i, card in enumerate(player.cards): if card.face_up: - # Calculate score if we swap here potential_change = drawn_value - get_ai_card_value(card, options) - potential_score = current_score + potential_change - if potential_score == 21: - # Aggressive players more likely to chase 21 + if current_score + potential_change == 21: if random.random() < profile.aggression: + ai_log(f" >> BLACKJACK: chasing 21 at position {i}") return i - # Consider swapping with face-down cards for very good cards (negative or zero value) - # 10s (ten_penny) become "excellent" cards worth keeping - is_excellent = (drawn_value <= 0 or - drawn_card.rank == Rank.ACE or - (options.ten_penny and drawn_card.rank == Rank.TEN)) + # Pair hunters might hold medium cards hoping for matches + if best_pos is not None and not player.cards[best_pos].face_up: + if drawn_value >= 5: # Only hold out for medium/high cards + pair_viability = get_pair_viability(drawn_card.rank, game) + phase = get_game_phase(game) + pressure = get_end_game_pressure(player, game) - # Calculate pair viability and game phase for smarter decisions - pair_viability = get_pair_viability(drawn_card.rank, game) - phase = get_game_phase(game) - pressure = get_end_game_pressure(player, game) - - if is_excellent: - face_down = [i for i, c in enumerate(player.cards) if not c.face_up] - if face_down: - # Pair hunters might hold out hoping for matches - # BUT: reduce hope if pair is unlikely or late game pressure effective_hope = profile.pair_hope * pair_viability if phase == 'late' or pressure > 0.5: - effective_hope *= 0.3 # Much less willing to gamble late game - if effective_hope > 0.6 and random.random() < effective_hope: - return None - return random.choice(face_down) + effective_hope *= 0.3 - # For medium cards, swap threshold based on profile - # Late game: be more willing to swap in medium cards - effective_threshold = profile.swap_threshold - if phase == 'late' or pressure > 0.5: - effective_threshold += 2 # Accept higher value cards under pressure + ai_log(f" Hold-for-pair check: value={drawn_value}, viability={pair_viability:.2f}, " + f"phase={phase}, effective_hope={effective_hope:.2f}") - if drawn_value <= effective_threshold: - face_down = [i for i, c in enumerate(player.cards) if not c.face_up] - if face_down: - # Pair hunters hold high cards hoping for matches - # BUT: check if pairing is actually viable - effective_hope = profile.pair_hope * pair_viability - if phase == 'late' or pressure > 0.5: - effective_hope *= 0.3 # Don't gamble late game - if effective_hope > 0.5 and drawn_value >= 6: - if random.random() < effective_hope: - return None - return random.choice(face_down) + if effective_hope > 0.5 and random.random() < effective_hope: + ai_log(f" >> HOLDING: discarding {drawn_card.rank.value} hoping for future pair") + return None # Discard and hope for pair later - return None + # Log final decision + if best_pos is not None: + target_card = player.cards[best_pos] + target_str = target_card.rank.value if target_card.face_up else "hidden" + ai_log(f" DECISION: SWAP into position {best_pos} (replacing {target_str}) [score={best_score:.2f}]") + else: + ai_log(f" DECISION: DISCARD {drawn_card.rank.value} (no good swap options)") + + return best_pos @staticmethod def choose_flip_after_discard(player: Player, profile: CPUProfile) -> int: @@ -665,7 +934,9 @@ async def process_cpu_turn( if swap_pos is None and game.drawn_from_discard: face_down = [i for i, c in enumerate(cpu_player.cards) if not c.face_up] if face_down: - swap_pos = random.choice(face_down) + # Filter out positions that would create bad pairs with negative cards + safe_positions = filter_bad_pair_positions(face_down, drawn, cpu_player, game.options) + swap_pos = random.choice(safe_positions) else: # All cards are face up - find worst card to replace (using house rules) worst_pos = 0 diff --git a/server/auth.py b/server/auth.py new file mode 100644 index 0000000..92ff130 --- /dev/null +++ b/server/auth.py @@ -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 diff --git a/server/config.py b/server/config.py new file mode 100644 index 0000000..c2f83b1 --- /dev/null +++ b/server/config.py @@ -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 diff --git a/server/constants.py b/server/constants.py index df5bf5d..add2c2a 100644 --- a/server/constants.py +++ b/server/constants.py @@ -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, diff --git a/server/game_log.py b/server/game_log.py index f984598..a239c5f 100644 --- a/server/game_log.py +++ b/server/game_log.py @@ -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, } diff --git a/server/games.db b/server/games.db new file mode 100644 index 0000000..378dc14 Binary files /dev/null and b/server/games.db differ diff --git a/server/main.py b/server/main.py index f41d21a..1013b9d 100644 --- a/server/main.py +++ b/server/main.py @@ -1,18 +1,33 @@ """FastAPI WebSocket server for Golf card game.""" -import uuid -import asyncio -from fastapi import FastAPI, WebSocket, WebSocketDisconnect -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse +import logging import os +import uuid +from typing import Optional +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, Header +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from config import config from room import RoomManager, Room from game import GamePhase, GameOptions from ai import GolfAI, process_cpu_turn, get_all_profiles from game_log import get_logger +from auth import get_auth_manager, User, UserRole -app = FastAPI(title="Golf Card Game") +# Configure logging +logging.basicConfig( + level=getattr(logging, config.LOG_LEVEL.upper(), logging.INFO), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + +app = FastAPI( + title="Golf Card Game", + debug=config.DEBUG, + version="0.1.0", +) room_manager = RoomManager() @@ -22,6 +37,374 @@ async def health_check(): return {"status": "ok"} +# ============================================================================= +# Auth Models +# ============================================================================= + +class RegisterRequest(BaseModel): + username: str + password: str + email: Optional[str] = None + invite_code: str # Room code or explicit invite code + + +class LoginRequest(BaseModel): + username: str + password: str + + +class SetupPasswordRequest(BaseModel): + username: str + new_password: str + + +class UpdateUserRequest(BaseModel): + username: Optional[str] = None + email: Optional[str] = None + role: Optional[str] = None + is_active: Optional[bool] = None + + +class ChangePasswordRequest(BaseModel): + new_password: str + + +class CreateInviteRequest(BaseModel): + max_uses: int = 1 + expires_in_days: Optional[int] = 7 + + +# ============================================================================= +# Auth Dependencies +# ============================================================================= + +async def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[User]: + """Get current user from Authorization header.""" + if not authorization: + return None + + # Expect "Bearer " + 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() diff --git a/server/requirements.txt b/server/requirements.txt index cb3770d..2aa4c44 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -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 diff --git a/server/score_distribution.png b/server/score_distribution.png new file mode 100644 index 0000000..fee186f Binary files /dev/null and b/server/score_distribution.png differ diff --git a/server/simulate.py b/server/simulate.py index d1d4e6f..a87bf4e 100644 --- a/server/simulate.py +++ b/server/simulate.py @@ -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) diff --git a/server/test_analyzer.py b/server/test_analyzer.py index 909020e..a6ab2d4 100644 --- a/server/test_analyzer.py +++ b/server/test_analyzer.py @@ -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 diff --git a/server/test_auth.py b/server/test_auth.py new file mode 100644 index 0000000..97dceec --- /dev/null +++ b/server/test_auth.py @@ -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"]) diff --git a/server/test_game.py b/server/test_game.py index 5c70289..68685d5 100644 --- a/server/test_game.py +++ b/server/test_game.py @@ -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) diff --git a/server/test_maya_bug.py b/server/test_maya_bug.py index 31aab3f..8596fe0 100644 --- a/server/test_maya_bug.py +++ b/server/test_maya_bug.py @@ -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"])